mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-09-30 19:36:41 +00:00
Feat: Add cardinality data distribution chart (#23244)
* Feat: Add cardinality data distribution chart * refactor: Simplify cardinality label in multiple languages
This commit is contained in:
parent
e66824cd45
commit
5c1b76a2c8
@ -0,0 +1,652 @@
|
||||
/*
|
||||
* Copyright 2023 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { AxiosError } from 'axios';
|
||||
import { DateRangeObject } from 'Models';
|
||||
import { OperationPermission } from '../../../../context/PermissionProvider/PermissionProvider.interface';
|
||||
import { ColumnProfile } from '../../../../generated/entity/data/container';
|
||||
import { Table } from '../../../../generated/entity/data/table';
|
||||
import { Operation } from '../../../../generated/entity/policies/accessControl/resourcePermission';
|
||||
import { getColumnProfilerList } from '../../../../rest/tableAPI';
|
||||
import { showErrorToast } from '../../../../utils/ToastUtils';
|
||||
import SingleColumnProfile from './SingleColumnProfile';
|
||||
import { useTableProfiler } from './TableProfilerProvider';
|
||||
|
||||
jest.mock('../../../../rest/tableAPI', () => ({
|
||||
getColumnProfilerList: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../utils/ToastUtils', () => ({
|
||||
showErrorToast: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./TableProfilerProvider', () => ({
|
||||
useTableProfiler: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../utils/TableProfilerUtils', () => ({
|
||||
calculateColumnProfilerMetrics: jest.fn().mockReturnValue({
|
||||
countMetrics: { data: [] },
|
||||
proportionMetrics: { data: [] },
|
||||
mathMetrics: { data: [] },
|
||||
sumMetrics: { data: [] },
|
||||
quartileMetrics: { data: [] },
|
||||
}),
|
||||
calculateCustomMetrics: jest.fn().mockReturnValue({}),
|
||||
getColumnCustomMetric: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../utils/DocumentationLinksClassBase', () => ({
|
||||
getDocsURLS: () => ({
|
||||
DATA_QUALITY_PROFILER_WORKFLOW_DOCS: 'https://docs.example.com/profiler',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../ProfilerDetailsCard/ProfilerDetailsCard', () => {
|
||||
return function MockProfilerDetailsCard(props: Record<string, unknown>) {
|
||||
return (
|
||||
<>
|
||||
<div data-testid={`profiler-details-card-${props.name as string}`}>
|
||||
{props.title as string}
|
||||
</div>
|
||||
{props.isLoading && <div data-testid="loading">Loading...</div>}
|
||||
{!props.isLoading &&
|
||||
(props.chartCollection as { data?: unknown[] })?.data?.length ===
|
||||
0 && (
|
||||
<div data-testid="no-data">
|
||||
{props.noDataPlaceholderText as string}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'../../../Visualisations/Chart/DataDistributionHistogram.component',
|
||||
() => {
|
||||
return function MockDataDistributionHistogram(
|
||||
props: Record<string, unknown>
|
||||
) {
|
||||
return (
|
||||
<div data-testid="data-distribution-histogram">
|
||||
{(props.data as { firstDayData?: unknown; currentDayData?: unknown })
|
||||
?.firstDayData ||
|
||||
(props.data as { firstDayData?: unknown; currentDayData?: unknown })
|
||||
?.currentDayData ? (
|
||||
<div>Histogram Data</div>
|
||||
) : (
|
||||
<div>{props.noDataPlaceholderText as string}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'../../../Visualisations/Chart/CardinalityDistributionChart.component',
|
||||
() => {
|
||||
return function MockCardinalityDistributionChart(
|
||||
props: Record<string, unknown>
|
||||
) {
|
||||
return (
|
||||
<div data-testid="cardinality-distribution-chart">
|
||||
{(props.data as { firstDayData?: unknown; currentDayData?: unknown })
|
||||
?.firstDayData ||
|
||||
(props.data as { firstDayData?: unknown; currentDayData?: unknown })
|
||||
?.currentDayData ? (
|
||||
<div>Cardinality Data</div>
|
||||
) : (
|
||||
<div>{props.noDataPlaceholderText as string}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
jest.mock('./CustomMetricGraphs/CustomMetricGraphs.component', () => {
|
||||
return function MockCustomMetricGraphs(props: Record<string, unknown>) {
|
||||
return (
|
||||
<>
|
||||
<div data-testid="custom-metric-graphs">Custom Metrics Component</div>
|
||||
{props.isLoading && <div data-testid="custom-loading">Loading...</div>}
|
||||
<div>
|
||||
{(props.customMetrics as unknown[])?.length || 0} custom metrics
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const mockColumnProfilerData: ColumnProfile[] = [
|
||||
{
|
||||
name: 'test_column',
|
||||
timestamp: 1704067200000,
|
||||
valuesCount: 1000,
|
||||
nullCount: 10,
|
||||
min: 1,
|
||||
max: 100,
|
||||
mean: 50.5,
|
||||
histogram: {
|
||||
boundaries: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100],
|
||||
frequencies: [100, 90, 80, 70, 60, 50, 40, 30, 20, 10],
|
||||
},
|
||||
cardinalityDistribution: {
|
||||
categories: ['low', 'medium', 'high'],
|
||||
counts: [300, 400, 300],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'test_column',
|
||||
timestamp: 1703980800000,
|
||||
valuesCount: 950,
|
||||
nullCount: 15,
|
||||
min: 0,
|
||||
max: 95,
|
||||
mean: 47.5,
|
||||
histogram: {
|
||||
boundaries: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100],
|
||||
frequencies: [95, 85, 75, 65, 55, 45, 35, 25, 15, 5],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockStringColumnProfilerData: ColumnProfile[] = [
|
||||
{
|
||||
name: 'string_column',
|
||||
timestamp: 1704067200000,
|
||||
valuesCount: 500,
|
||||
nullCount: 5,
|
||||
min: 'apple',
|
||||
max: 'zebra',
|
||||
distinctCount: 450,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDateRangeObject: DateRangeObject = {
|
||||
startTs: 1703980800000,
|
||||
endTs: 1704067200000,
|
||||
key: 'last_7_days',
|
||||
};
|
||||
|
||||
const mockTableDetails: Table = {
|
||||
id: 'table-id',
|
||||
name: 'test_table',
|
||||
fullyQualifiedName: 'db.schema.test_table',
|
||||
columns: [],
|
||||
customMetrics: [
|
||||
{
|
||||
id: 'metric-1',
|
||||
name: 'custom_metric_1',
|
||||
expression: 'SELECT COUNT(*) FROM test_table',
|
||||
updatedAt: 1704067200000,
|
||||
updatedBy: 'admin',
|
||||
},
|
||||
],
|
||||
} as Table;
|
||||
|
||||
const mockPermissions: OperationPermission = Object.values(Operation).reduce(
|
||||
(acc, operation) => ({ ...acc, [operation]: true }),
|
||||
{} as OperationPermission
|
||||
);
|
||||
|
||||
const defaultTableProfilerContext = {
|
||||
permissions: mockPermissions,
|
||||
isTestsLoading: false,
|
||||
isProfilerDataLoading: false,
|
||||
customMetric: undefined,
|
||||
allTestCases: [],
|
||||
overallSummary: [],
|
||||
onTestCaseUpdate: jest.fn(),
|
||||
onSettingButtonClick: jest.fn(),
|
||||
fetchAllTests: jest.fn(),
|
||||
onCustomMetricUpdate: jest.fn(),
|
||||
isProfilingEnabled: true,
|
||||
dateRangeObject: { startTs: 0, endTs: 0, key: '' },
|
||||
onDateRangeChange: jest.fn(),
|
||||
testCasePaging: {
|
||||
currentPage: 1,
|
||||
paging: { total: 0 },
|
||||
pageSize: 10,
|
||||
showPagination: false,
|
||||
handlePageChange: jest.fn(),
|
||||
handlePagingChange: jest.fn(),
|
||||
handlePageSizeChange: jest.fn(),
|
||||
pagingCursor: {
|
||||
cursorType: undefined,
|
||||
cursorValue: undefined,
|
||||
currentPage: '1',
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
isTestCaseDrawerOpen: false,
|
||||
onTestCaseDrawerOpen: jest.fn(),
|
||||
};
|
||||
|
||||
const mockGetColumnProfilerList = getColumnProfilerList as jest.MockedFunction<
|
||||
typeof getColumnProfilerList
|
||||
>;
|
||||
const mockUseTableProfiler = useTableProfiler as jest.MockedFunction<
|
||||
typeof useTableProfiler
|
||||
>;
|
||||
const mockShowErrorToast = showErrorToast as jest.MockedFunction<
|
||||
typeof showErrorToast
|
||||
>;
|
||||
|
||||
describe('SingleColumnProfile', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseTableProfiler.mockReturnValue(defaultTableProfilerContext);
|
||||
mockGetColumnProfilerList.mockResolvedValue({
|
||||
data: mockColumnProfilerData,
|
||||
paging: { total: mockColumnProfilerData.length },
|
||||
});
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
activeColumnFqn: 'db.schema.test_table.test_column',
|
||||
dateRangeObject: mockDateRangeObject,
|
||||
tableDetails: mockTableDetails,
|
||||
};
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render all profiler detail cards', async () => {
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('profiler-tab-container')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('profiler-details-card-count')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('profiler-details-card-proportion')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('profiler-details-card-math')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('profiler-details-card-sum')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('profiler-details-card-quartile')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render histogram section when histogram data is available', async () => {
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('histogram-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('data-distribution-title')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('data-distribution-histogram')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render cardinality distribution section when cardinality data is available', async () => {
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('cardinality-distribution-metrics')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('cardinality-distribution-title')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('cardinality-distribution-chart')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render histogram section when no histogram data', async () => {
|
||||
mockGetColumnProfilerList.mockResolvedValue({
|
||||
data: [{ ...mockColumnProfilerData[0], histogram: undefined }],
|
||||
paging: { total: 1 },
|
||||
});
|
||||
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('profiler-tab-container')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('histogram-metrics')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render cardinality section when no cardinality data', async () => {
|
||||
mockGetColumnProfilerList.mockResolvedValue({
|
||||
data: [
|
||||
{ ...mockColumnProfilerData[0], cardinalityDistribution: undefined },
|
||||
],
|
||||
paging: { total: 1 },
|
||||
});
|
||||
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('profiler-tab-container')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('cardinality-distribution-metrics')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should show loading state initially', () => {
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
expect(screen.getAllByTestId('loading')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should show loading state from TableProfiler context', async () => {
|
||||
mockUseTableProfiler.mockReturnValue({
|
||||
...defaultTableProfilerContext,
|
||||
isProfilerDataLoading: true,
|
||||
});
|
||||
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('custom-loading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Fetching', () => {
|
||||
it('should fetch column profiler data with correct parameters', async () => {
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetColumnProfilerList).toHaveBeenCalledWith(
|
||||
'db.schema.test_table.test_column',
|
||||
{ startTs: 1703980800000, endTs: 1704067200000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch data with default range when dateRangeObject is not provided', async () => {
|
||||
render(
|
||||
<SingleColumnProfile
|
||||
{...defaultProps}
|
||||
dateRangeObject={undefined as unknown as DateRangeObject}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetColumnProfilerList).toHaveBeenCalledWith(
|
||||
'db.schema.test_table.test_column',
|
||||
expect.objectContaining({
|
||||
startTs: expect.any(Number),
|
||||
endTs: expect.any(Number),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not fetch data when activeColumnFqn is empty', async () => {
|
||||
render(<SingleColumnProfile {...defaultProps} activeColumnFqn="" />);
|
||||
|
||||
expect(mockGetColumnProfilerList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refetch data when activeColumnFqn changes', async () => {
|
||||
const { rerender } = render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetColumnProfilerList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
rerender(
|
||||
<SingleColumnProfile
|
||||
{...defaultProps}
|
||||
activeColumnFqn="db.schema.test_table.new_column"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetColumnProfilerList).toHaveBeenCalledTimes(2);
|
||||
expect(mockGetColumnProfilerList).toHaveBeenLastCalledWith(
|
||||
'db.schema.test_table.new_column',
|
||||
{ startTs: 1703980800000, endTs: 1704067200000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should refetch data when dateRangeObject changes', async () => {
|
||||
const { rerender } = render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetColumnProfilerList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const newDateRange = {
|
||||
startTs: 1703894400000,
|
||||
endTs: 1703980800000,
|
||||
key: 'last_1_day',
|
||||
};
|
||||
|
||||
rerender(
|
||||
<SingleColumnProfile {...defaultProps} dateRangeObject={newDateRange} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetColumnProfilerList).toHaveBeenCalledTimes(2);
|
||||
expect(mockGetColumnProfilerList).toHaveBeenLastCalledWith(
|
||||
'db.schema.test_table.test_column',
|
||||
{ startTs: 1703894400000, endTs: 1703980800000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should show error toast when API call fails', async () => {
|
||||
const error = new AxiosError('API Error');
|
||||
mockGetColumnProfilerList.mockRejectedValue(error);
|
||||
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowErrorToast).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set loading to false after error', async () => {
|
||||
const error = new AxiosError('API Error');
|
||||
mockGetColumnProfilerList.mockRejectedValue(error);
|
||||
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowErrorToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('String Data Handling', () => {
|
||||
it('should handle string min/max values correctly', async () => {
|
||||
mockGetColumnProfilerList.mockResolvedValue({
|
||||
data: mockStringColumnProfilerData,
|
||||
paging: { total: mockStringColumnProfilerData.length },
|
||||
});
|
||||
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('profiler-tab-container')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('No Data States', () => {
|
||||
it('should show appropriate message when profiling is enabled but no data', async () => {
|
||||
mockGetColumnProfilerList.mockResolvedValue({
|
||||
data: [],
|
||||
paging: { total: 0 },
|
||||
});
|
||||
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('profiler-tab-container')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show documentation link when profiling is disabled', async () => {
|
||||
mockUseTableProfiler.mockReturnValue({
|
||||
...defaultTableProfilerContext,
|
||||
isProfilingEnabled: false,
|
||||
});
|
||||
mockGetColumnProfilerList.mockResolvedValue({
|
||||
data: [],
|
||||
paging: { total: 0 },
|
||||
});
|
||||
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('profiler-tab-container')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Metrics Integration', () => {
|
||||
it('should use custom metrics from tableDetails when provided', async () => {
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should fallback to custom metrics from context when tableDetails not provided', async () => {
|
||||
mockUseTableProfiler.mockReturnValue({
|
||||
...defaultTableProfilerContext,
|
||||
customMetric: mockTableDetails,
|
||||
});
|
||||
|
||||
render(
|
||||
<SingleColumnProfile {...defaultProps} tableDetails={undefined} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty custom metrics array', async () => {
|
||||
render(
|
||||
<SingleColumnProfile
|
||||
{...defaultProps}
|
||||
tableDetails={{ ...mockTableDetails, customMetrics: undefined }}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Processing', () => {
|
||||
it('should process first and last day data correctly', async () => {
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('data-distribution-histogram')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Histogram Data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle single data point correctly', async () => {
|
||||
mockGetColumnProfilerList.mockResolvedValue({
|
||||
data: [mockColumnProfilerData[0]],
|
||||
paging: { total: 1 },
|
||||
});
|
||||
|
||||
render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('profiler-tab-container')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Updates', () => {
|
||||
it('should update metrics when column profiler data changes', async () => {
|
||||
const { rerender } = render(<SingleColumnProfile {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('profiler-tab-container')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
mockGetColumnProfilerList.mockResolvedValue({
|
||||
data: [mockStringColumnProfilerData[0]],
|
||||
paging: { total: 1 },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<SingleColumnProfile
|
||||
{...defaultProps}
|
||||
activeColumnFqn="db.schema.test_table.string_column"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetColumnProfilerList).toHaveBeenCalledWith(
|
||||
'db.schema.test_table.string_column',
|
||||
{ startTs: 1703980800000, endTs: 1704067200000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -32,6 +32,7 @@ import {
|
||||
} from '../../../../utils/TableProfilerUtils';
|
||||
import { ColumnMetricsInterface } from '../../../../utils/TableProfilerUtils.interface';
|
||||
import { showErrorToast } from '../../../../utils/ToastUtils';
|
||||
import CardinalityDistributionChart from '../../../Visualisations/Chart/CardinalityDistributionChart.component';
|
||||
import DataDistributionHistogram from '../../../Visualisations/Chart/DataDistributionHistogram.component';
|
||||
import ProfilerDetailsCard from '../ProfilerDetailsCard/ProfilerDetailsCard';
|
||||
import CustomMetricGraphs from './CustomMetricGraphs/CustomMetricGraphs.component';
|
||||
@ -202,26 +203,55 @@ const SingleColumnProfile: FC<SingleColumnProfileProps> = ({
|
||||
title={t('label.data-quartile-plural')}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Card
|
||||
className="shadow-none global-border-radius"
|
||||
data-testid="histogram-metrics"
|
||||
loading={isLoading}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Typography.Title data-testid="data-distribution-title" level={5}>
|
||||
{t('label.data-distribution')}
|
||||
</Typography.Title>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<DataDistributionHistogram
|
||||
data={{ firstDayData: firstDay, currentDayData: currentDay }}
|
||||
noDataPlaceholderText={noProfilerMessage}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
{firstDay?.histogram || currentDay?.histogram ? (
|
||||
<Col span={24}>
|
||||
<Card
|
||||
className="shadow-none global-border-radius"
|
||||
data-testid="histogram-metrics"
|
||||
loading={isLoading}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Typography.Title
|
||||
data-testid="data-distribution-title"
|
||||
level={5}>
|
||||
{t('label.data-distribution')}
|
||||
</Typography.Title>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<DataDistributionHistogram
|
||||
data={{ firstDayData: firstDay, currentDayData: currentDay }}
|
||||
noDataPlaceholderText={noProfilerMessage}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{firstDay?.cardinalityDistribution ||
|
||||
currentDay?.cardinalityDistribution ? (
|
||||
<Col span={24}>
|
||||
<Card
|
||||
className="shadow-none global-border-radius"
|
||||
data-testid="cardinality-distribution-metrics"
|
||||
loading={isLoading}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Typography.Title
|
||||
data-testid="cardinality-distribution-title"
|
||||
level={5}>
|
||||
{t('label.cardinality')}
|
||||
</Typography.Title>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<CardinalityDistributionChart
|
||||
data={{ firstDayData: firstDay, currentDayData: currentDay }}
|
||||
noDataPlaceholderText={noProfilerMessage}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
<Col span={24}>
|
||||
<CustomMetricGraphs
|
||||
customMetrics={customMetrics}
|
||||
|
@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright 2023 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Card, Col, Row, Tag } from 'antd';
|
||||
import { isUndefined, map } from 'lodash';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
TooltipProps,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { CHART_BLUE_1 } from '../../../constants/Color.constants';
|
||||
import { GRAPH_BACKGROUND_COLOR } from '../../../constants/constants';
|
||||
import { ColumnProfile } from '../../../generated/entity/data/table';
|
||||
import { axisTickFormatter, tooltipFormatter } from '../../../utils/ChartUtils';
|
||||
import { customFormatDateTime } from '../../../utils/date-time/DateTimeUtils';
|
||||
import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder';
|
||||
|
||||
export interface CardinalityDistributionChartProps {
|
||||
data: {
|
||||
firstDayData?: ColumnProfile;
|
||||
currentDayData?: ColumnProfile;
|
||||
};
|
||||
noDataPlaceholderText?: string | React.ReactNode;
|
||||
}
|
||||
|
||||
const CardinalityDistributionChart = ({
|
||||
data,
|
||||
noDataPlaceholderText,
|
||||
}: CardinalityDistributionChartProps) => {
|
||||
const { t } = useTranslation();
|
||||
const showSingleGraph =
|
||||
isUndefined(data.firstDayData?.cardinalityDistribution) ||
|
||||
isUndefined(data.currentDayData?.cardinalityDistribution);
|
||||
|
||||
if (
|
||||
isUndefined(data.firstDayData?.cardinalityDistribution) &&
|
||||
isUndefined(data.currentDayData?.cardinalityDistribution)
|
||||
) {
|
||||
return (
|
||||
<Row align="middle" className="h-full w-full" justify="center">
|
||||
<Col>
|
||||
<ErrorPlaceHolder placeholderText={noDataPlaceholderText} />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
const renderTooltip: TooltipProps<string | number, string>['content'] = (
|
||||
props
|
||||
) => {
|
||||
const { active, payload } = props;
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<p className="font-semibold text-sm mb-1">{`${t('label.category')}: ${
|
||||
data.name
|
||||
}`}</p>
|
||||
<p className="text-sm mb-1">{`${t('label.count')}: ${tooltipFormatter(
|
||||
data.count
|
||||
)}`}</p>
|
||||
<p className="text-sm">{`${t('label.percentage')}: ${
|
||||
data.percentage
|
||||
}%`}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Row className="w-full" data-testid="chart-container">
|
||||
{map(data, (columnProfile, key) => {
|
||||
if (
|
||||
isUndefined(columnProfile) ||
|
||||
isUndefined(columnProfile?.cardinalityDistribution)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cardinalityData = columnProfile.cardinalityDistribution;
|
||||
|
||||
const graphData =
|
||||
cardinalityData.categories?.map((category, i) => ({
|
||||
name: category,
|
||||
count: cardinalityData.counts?.[i] || 0,
|
||||
percentage: cardinalityData.percentages?.[i] || 0,
|
||||
})) || [];
|
||||
|
||||
const graphDate = customFormatDateTime(
|
||||
columnProfile?.timestamp || 0,
|
||||
'MMM dd'
|
||||
);
|
||||
|
||||
return (
|
||||
<Col key={key} span={showSingleGraph ? 24 : 12}>
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col
|
||||
data-testid="date"
|
||||
offset={showSingleGraph ? 1 : 2}
|
||||
span={24}>
|
||||
{graphDate}
|
||||
</Col>
|
||||
<Col offset={showSingleGraph ? 1 : 2} span={24}>
|
||||
<Tag data-testid="cardinality-tag">{`${t('label.total-entity', {
|
||||
entity: t('label.category-plural'),
|
||||
})}: ${cardinalityData.categories?.length || 0}`}</Tag>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<ResponsiveContainer
|
||||
debounce={200}
|
||||
id={`${key}-cardinality`}
|
||||
minHeight={300}>
|
||||
<BarChart
|
||||
className="w-full"
|
||||
data={graphData}
|
||||
layout="vertical"
|
||||
margin={{ left: 16 }}>
|
||||
<CartesianGrid stroke={GRAPH_BACKGROUND_COLOR} />
|
||||
<XAxis
|
||||
padding={{ left: 16, right: 16 }}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(props) => axisTickFormatter(props, '%')}
|
||||
type="number"
|
||||
/>
|
||||
<YAxis
|
||||
allowDataOverflow
|
||||
dataKey="name"
|
||||
padding={{ top: 16, bottom: 16 }}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(value: string) =>
|
||||
value?.length > 15 ? `${value.slice(0, 15)}...` : value
|
||||
}
|
||||
type="category"
|
||||
width={120}
|
||||
/>
|
||||
<Legend />
|
||||
<Tooltip content={renderTooltip} />
|
||||
<Bar dataKey="percentage" fill={CHART_BLUE_1} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardinalityDistributionChart;
|
@ -0,0 +1,472 @@
|
||||
/*
|
||||
* Copyright 2023 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { queryByAttribute, render, screen } from '@testing-library/react';
|
||||
import { ColumnProfile } from '../../../generated/entity/data/table';
|
||||
import CardinalityDistributionChart, {
|
||||
CardinalityDistributionChartProps,
|
||||
} from './CardinalityDistributionChart.component';
|
||||
|
||||
// Use existing recharts mock
|
||||
import '../../../test/unit/mocks/recharts.mock';
|
||||
|
||||
// Mock utility functions
|
||||
jest.mock('../../../utils/ChartUtils', () => ({
|
||||
axisTickFormatter: jest.fn(
|
||||
(value: string, suffix: string) => `${value}${suffix}`
|
||||
),
|
||||
tooltipFormatter: jest.fn((value: number) => value.toLocaleString()),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/date-time/DateTimeUtils', () => ({
|
||||
customFormatDateTime: jest.fn((timestamp: number) => {
|
||||
if (timestamp === 1704067200000) {
|
||||
return 'Jan 01';
|
||||
}
|
||||
if (timestamp === 1703980800000) {
|
||||
return 'Dec 30';
|
||||
}
|
||||
|
||||
return 'Unknown Date';
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../common/ErrorWithPlaceholder/ErrorPlaceHolder', () => {
|
||||
return function MockErrorPlaceHolder({
|
||||
placeholderText,
|
||||
}: {
|
||||
placeholderText: string | React.ReactNode;
|
||||
}) {
|
||||
return <div data-testid="error-placeholder">{placeholderText}</div>;
|
||||
};
|
||||
});
|
||||
|
||||
const mockColumnProfileWithCardinality: ColumnProfile = {
|
||||
name: 'test_column',
|
||||
timestamp: 1704067200000,
|
||||
valuesCount: 1000,
|
||||
nullCount: 10,
|
||||
cardinalityDistribution: {
|
||||
categories: ['low', 'medium', 'high', 'very_high'],
|
||||
counts: [100, 300, 400, 200],
|
||||
percentages: [10, 30, 40, 20],
|
||||
},
|
||||
};
|
||||
|
||||
const mockColumnProfileWithoutCardinality: ColumnProfile = {
|
||||
name: 'test_column',
|
||||
timestamp: 1704067200000,
|
||||
valuesCount: 1000,
|
||||
nullCount: 10,
|
||||
};
|
||||
|
||||
const mockSecondColumnProfile: ColumnProfile = {
|
||||
name: 'test_column',
|
||||
timestamp: 1703980800000,
|
||||
valuesCount: 950,
|
||||
nullCount: 15,
|
||||
cardinalityDistribution: {
|
||||
categories: ['low', 'medium', 'high'],
|
||||
counts: [150, 400, 400],
|
||||
percentages: [15.8, 42.1, 42.1],
|
||||
},
|
||||
};
|
||||
|
||||
describe('CardinalityDistributionChart', () => {
|
||||
const defaultProps: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
firstDayData: mockColumnProfileWithCardinality,
|
||||
currentDayData: mockSecondColumnProfile,
|
||||
},
|
||||
};
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render chart container when data is provided', async () => {
|
||||
render(<CardinalityDistributionChart {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByTestId('chart-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render dual charts when both firstDayData and currentDayData have cardinality data', async () => {
|
||||
const { container } = render(
|
||||
<CardinalityDistributionChart {...defaultProps} />
|
||||
);
|
||||
|
||||
expect(await screen.findAllByTestId('date')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('cardinality-tag')).toHaveLength(2);
|
||||
expect(
|
||||
queryByAttribute('id', container, 'firstDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
queryByAttribute('id', container, 'currentDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render single chart when only currentDayData has cardinality data', async () => {
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
firstDayData: mockColumnProfileWithoutCardinality,
|
||||
currentDayData: mockSecondColumnProfile,
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
expect(await screen.findAllByTestId('date')).toHaveLength(1);
|
||||
expect(await screen.findAllByTestId('cardinality-tag')).toHaveLength(1);
|
||||
expect(
|
||||
queryByAttribute('id', container, 'firstDayData-cardinality')
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
queryByAttribute('id', container, 'currentDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render single chart when only firstDayData has cardinality data', async () => {
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
firstDayData: mockColumnProfileWithCardinality,
|
||||
currentDayData: mockColumnProfileWithoutCardinality,
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
expect(await screen.findAllByTestId('date')).toHaveLength(1);
|
||||
expect(await screen.findAllByTestId('cardinality-tag')).toHaveLength(1);
|
||||
expect(
|
||||
queryByAttribute('id', container, 'firstDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
queryByAttribute('id', container, 'currentDayData-cardinality')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error placeholder when no cardinality data is available', async () => {
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
firstDayData: mockColumnProfileWithoutCardinality,
|
||||
currentDayData: mockColumnProfileWithoutCardinality,
|
||||
},
|
||||
noDataPlaceholderText: 'No cardinality data available',
|
||||
};
|
||||
|
||||
render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('error-placeholder')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText('No cardinality data available')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chart-container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error placeholder with default message when no cardinality data and no placeholder text', async () => {
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
firstDayData: mockColumnProfileWithoutCardinality,
|
||||
currentDayData: mockColumnProfileWithoutCardinality,
|
||||
},
|
||||
};
|
||||
|
||||
render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('error-placeholder')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chart-container')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Processing', () => {
|
||||
it('should render charts when cardinality data is provided', async () => {
|
||||
const { container } = render(
|
||||
<CardinalityDistributionChart {...defaultProps} />
|
||||
);
|
||||
|
||||
// Should render chart containers with unique IDs
|
||||
expect(
|
||||
queryByAttribute('id', container, 'firstDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
queryByAttribute('id', container, 'currentDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle missing counts and percentages gracefully', async () => {
|
||||
const incompleteProfile: ColumnProfile = {
|
||||
name: 'test_column',
|
||||
timestamp: 1704067200000,
|
||||
valuesCount: 1000,
|
||||
nullCount: 10,
|
||||
cardinalityDistribution: {
|
||||
categories: ['low', 'medium', 'high'],
|
||||
counts: [100], // Missing counts for medium and high
|
||||
percentages: [10, 30], // Missing percentage for high
|
||||
},
|
||||
};
|
||||
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
currentDayData: incompleteProfile,
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
// Should still render the chart container
|
||||
expect(await screen.findByTestId('chart-container')).toBeInTheDocument();
|
||||
expect(
|
||||
queryByAttribute('id', container, 'currentDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty categories array', async () => {
|
||||
const emptyProfile: ColumnProfile = {
|
||||
name: 'test_column',
|
||||
timestamp: 1704067200000,
|
||||
valuesCount: 1000,
|
||||
nullCount: 10,
|
||||
cardinalityDistribution: {
|
||||
categories: [],
|
||||
counts: [],
|
||||
percentages: [],
|
||||
},
|
||||
};
|
||||
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
currentDayData: emptyProfile,
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
// Should still render the chart container even with empty data
|
||||
expect(await screen.findByTestId('chart-container')).toBeInTheDocument();
|
||||
expect(
|
||||
queryByAttribute('id', container, 'currentDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Date and Tag Display', () => {
|
||||
it('should display formatted dates correctly', async () => {
|
||||
render(<CardinalityDistributionChart {...defaultProps} />);
|
||||
|
||||
const dateElements = await screen.findAllByTestId('date');
|
||||
|
||||
expect(dateElements[0]).toHaveTextContent('Jan 01');
|
||||
expect(dateElements[1]).toHaveTextContent('Dec 30');
|
||||
});
|
||||
|
||||
it('should display total categories count in tags', async () => {
|
||||
render(<CardinalityDistributionChart {...defaultProps} />);
|
||||
|
||||
const cardinalityTags = await screen.findAllByTestId('cardinality-tag');
|
||||
|
||||
expect(cardinalityTags[0]).toHaveTextContent('label.total-entity: 4');
|
||||
expect(cardinalityTags[1]).toHaveTextContent('label.total-entity: 3');
|
||||
});
|
||||
|
||||
it('should handle zero categories in tag', async () => {
|
||||
const emptyProfile: ColumnProfile = {
|
||||
name: 'test_column',
|
||||
timestamp: 1704067200000,
|
||||
valuesCount: 1000,
|
||||
nullCount: 10,
|
||||
cardinalityDistribution: {
|
||||
categories: [],
|
||||
counts: [],
|
||||
percentages: [],
|
||||
},
|
||||
};
|
||||
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
currentDayData: emptyProfile,
|
||||
},
|
||||
};
|
||||
|
||||
render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
const cardinalityTag = await screen.findByTestId('cardinality-tag');
|
||||
|
||||
expect(cardinalityTag).toHaveTextContent('label.total-entity: 0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout and Styling', () => {
|
||||
it('should use correct layout for single graph', async () => {
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
currentDayData: mockColumnProfileWithCardinality,
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
// Single graph should render one chart
|
||||
expect(await screen.findAllByTestId('date')).toHaveLength(1);
|
||||
expect(
|
||||
queryByAttribute('id', container, 'currentDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use correct layout for dual graphs', async () => {
|
||||
const { container } = render(
|
||||
<CardinalityDistributionChart {...defaultProps} />
|
||||
);
|
||||
|
||||
// Dual graphs should render two charts
|
||||
expect(await screen.findAllByTestId('date')).toHaveLength(2);
|
||||
expect(
|
||||
queryByAttribute('id', container, 'firstDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
queryByAttribute('id', container, 'currentDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined data gracefully', async () => {
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {},
|
||||
};
|
||||
|
||||
render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('error-placeholder')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chart-container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle data with undefined cardinality distribution', async () => {
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
firstDayData: {
|
||||
...mockColumnProfileWithCardinality,
|
||||
cardinalityDistribution: undefined,
|
||||
},
|
||||
currentDayData: {
|
||||
...mockSecondColumnProfile,
|
||||
cardinalityDistribution: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('error-placeholder')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chart-container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle missing timestamp gracefully', async () => {
|
||||
const profileWithoutTimestamp: ColumnProfile = {
|
||||
...mockColumnProfileWithCardinality,
|
||||
timestamp: undefined as unknown as number,
|
||||
};
|
||||
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
currentDayData: profileWithoutTimestamp,
|
||||
},
|
||||
};
|
||||
|
||||
render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
const dateElement = await screen.findByTestId('date');
|
||||
|
||||
expect(dateElement).toHaveTextContent('Unknown Date');
|
||||
});
|
||||
|
||||
it('should handle React node as noDataPlaceholderText', async () => {
|
||||
const customPlaceholder = <div>Custom No Data Message</div>;
|
||||
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
firstDayData: mockColumnProfileWithoutCardinality,
|
||||
currentDayData: mockColumnProfileWithoutCardinality,
|
||||
},
|
||||
noDataPlaceholderText: customPlaceholder,
|
||||
};
|
||||
|
||||
render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('error-placeholder')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText('Custom No Data Message')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle undefined columnProfile data', async () => {
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
firstDayData: undefined,
|
||||
currentDayData: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('error-placeholder')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chart-container')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Integration', () => {
|
||||
it('should render with both data sources having different category counts', async () => {
|
||||
const { container } = render(
|
||||
<CardinalityDistributionChart {...defaultProps} />
|
||||
);
|
||||
|
||||
const cardinalityTags = await screen.findAllByTestId('cardinality-tag');
|
||||
|
||||
// First profile has 4 categories, second has 3
|
||||
expect(cardinalityTags[0]).toHaveTextContent('4');
|
||||
expect(cardinalityTags[1]).toHaveTextContent('3');
|
||||
|
||||
// Both charts should render
|
||||
expect(
|
||||
queryByAttribute('id', container, 'firstDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
queryByAttribute('id', container, 'currentDayData-cardinality')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without custom placeholder text', async () => {
|
||||
const props: CardinalityDistributionChartProps = {
|
||||
data: {
|
||||
firstDayData: mockColumnProfileWithoutCardinality,
|
||||
currentDayData: mockColumnProfileWithoutCardinality,
|
||||
},
|
||||
};
|
||||
|
||||
render(<CardinalityDistributionChart {...props} />);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('error-placeholder')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
@ -24,6 +24,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { CHART_BLUE_1 } from '../../../constants/Color.constants';
|
||||
import { GRAPH_BACKGROUND_COLOR } from '../../../constants/constants';
|
||||
import { DEFAULT_HISTOGRAM_DATA } from '../../../constants/profiler.constant';
|
||||
import { HistogramClass } from '../../../generated/entity/data/table';
|
||||
@ -116,7 +117,7 @@ const DataDistributionHistogram = ({
|
||||
tooltipFormatter(value)
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="frequency" fill="#1890FF" />
|
||||
<Bar dataKey="frequency" fill={CHART_BLUE_1} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Col>
|
||||
|
@ -29,6 +29,7 @@ export const LIGHT_GRAY = '#F1F4F9';
|
||||
export const INDIGO_1 = '#3538CD';
|
||||
export const PRIMARY_COLOR = DEFAULT_THEME.primaryColor;
|
||||
export const BLUE_2 = '#3ca2f4';
|
||||
export const CHART_BLUE_1 = '#1890FF';
|
||||
export const RIPTIDE = '#76E9C6';
|
||||
export const MY_SIN = '#FEB019';
|
||||
export const SAN_MARINO = '#416BB3';
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "von",
|
||||
"ca-certs": "CA-Zertifikate",
|
||||
"cancel": "Abbrechen",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "Kategorie",
|
||||
"category-plural": "Kategorien",
|
||||
"certification": "Zertifizierung",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "by",
|
||||
"ca-certs": "CA Certs",
|
||||
"cancel": "Cancel",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "Category",
|
||||
"category-plural": "Categories",
|
||||
"certification": "Certification",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "por",
|
||||
"ca-certs": "Certificados CA",
|
||||
"cancel": "Cancelar",
|
||||
"cardinality": "Cardinalidad",
|
||||
"category": "Categoría",
|
||||
"category-plural": "Categorías",
|
||||
"certification": "Certificación",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "par",
|
||||
"ca-certs": "Certificats CA",
|
||||
"cancel": "Annuler",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "Category",
|
||||
"category-plural": "Categories",
|
||||
"certification": "Certification",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "por",
|
||||
"ca-certs": "Certificados CA",
|
||||
"cancel": "Cancelar",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "Categoría",
|
||||
"category-plural": "Categorías",
|
||||
"certification": "Certificación",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "לפי",
|
||||
"ca-certs": "תעודות CA",
|
||||
"cancel": "ביטול",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "Category",
|
||||
"category-plural": "קטגוריות",
|
||||
"certification": "Certification",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "による",
|
||||
"ca-certs": "CA証明書",
|
||||
"cancel": "キャンセル",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "カテゴリ",
|
||||
"category-plural": "カテゴリ",
|
||||
"certification": "認証",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "작성자",
|
||||
"ca-certs": "CA 인증서",
|
||||
"cancel": "취소",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "카테고리",
|
||||
"category-plural": "카테고리들",
|
||||
"certification": "인증",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "द्वारे",
|
||||
"ca-certs": "CA प्रमाणपत्रे",
|
||||
"cancel": "रद्द करा",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "श्रेणी",
|
||||
"category-plural": "श्रेण्या",
|
||||
"certification": "प्रमाणपत्र",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "door",
|
||||
"ca-certs": "CA-certificaten",
|
||||
"cancel": "Annuleren",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "Categorie",
|
||||
"category-plural": "Categorieën",
|
||||
"certification": "Certification",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "توسط",
|
||||
"ca-certs": "گواهیهای CA",
|
||||
"cancel": "لغو",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "دستهبندی",
|
||||
"category-plural": "دستهبندیها",
|
||||
"certification": "Certification",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "por",
|
||||
"ca-certs": "Certificados CA",
|
||||
"cancel": "Cancelar",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "Categoria",
|
||||
"category-plural": "Categorias",
|
||||
"certification": "Certificação",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "por",
|
||||
"ca-certs": "Certificados CA",
|
||||
"cancel": "Cancelar",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "Category",
|
||||
"category-plural": "Categorias",
|
||||
"certification": "Certification",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "к",
|
||||
"ca-certs": "Сертификаты CA",
|
||||
"cancel": "Отмена",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "Категория",
|
||||
"category-plural": "Категории",
|
||||
"certification": "Сертификация",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "โดย",
|
||||
"ca-certs": "ใบรับรอง CA",
|
||||
"cancel": "ยกเลิก",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "หมวดหมู่",
|
||||
"category-plural": "หมวดหมู่หลายรายการ",
|
||||
"certification": "Certification",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "tarafından",
|
||||
"ca-certs": "CA Sertifikaları",
|
||||
"cancel": "İptal",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "Kategori",
|
||||
"category-plural": "Kategoriler",
|
||||
"certification": "Sertifikasyon",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "by",
|
||||
"ca-certs": "CA 证书",
|
||||
"cancel": "取消",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "分类",
|
||||
"category-plural": "分类",
|
||||
"certification": "Certification",
|
||||
|
@ -194,6 +194,7 @@
|
||||
"by-lowercase": "由",
|
||||
"ca-certs": "CA 憑證",
|
||||
"cancel": "取消",
|
||||
"cardinality": "Cardinality",
|
||||
"category": "類別",
|
||||
"category-plural": "類別",
|
||||
"certification": "認證",
|
||||
|
Loading…
x
Reference in New Issue
Block a user