From 69732d9dca662a415e020488be98f5bacbb7de76 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:56:51 -0300 Subject: [PATCH] fix(superset-ui-core): achieve 100% coverage for npm run core:cover (#38397) --- .../src/utils/metricColumnFilter.test.ts | 8 + .../test/utils/getColorFormatters.test.ts | 41 ++ .../Matrixify/MatrixifyGridGenerator.test.ts | 120 ++++ .../Matrixify/MatrixifyGridGenerator.ts | 8 +- .../src/chart/types/matrixify.mocks.test.ts | 28 + .../src/chart/types/matrixify.test.ts | 82 +++ .../src/chart/types/matrixify.ts | 36 +- .../AsyncAceEditor/useJsonValidation.test.ts | 25 + .../src/components/List/List.test.tsx | 10 + .../src/components/Select/constants.test.ts | 49 ++ .../Table/utils/InteractiveTableUtils.test.ts | 574 ++++++++++++++++++ .../src/types/react-syntax-highlighter.d.ts | 7 - .../src/utils/rankedSearchCompare.test.ts | 20 + .../src/utils/withLabel.test.ts | 39 ++ .../test/connection/SupersetClient.test.ts | 6 + .../connection/SupersetClientClass.test.ts | 33 + .../currency-format/CurrencyFormatter.test.ts | 78 +++ .../test/currency-format/utils.test.ts | 193 ++++++ .../test/query/types/Column.test.ts | 14 + .../test/query/types/Dashboard.test.ts | 33 + 20 files changed, 1373 insertions(+), 31 deletions(-) create mode 100644 superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.mocks.test.ts create mode 100644 superset-frontend/packages/superset-ui-core/src/components/Select/constants.test.ts create mode 100644 superset-frontend/packages/superset-ui-core/src/components/Table/utils/InteractiveTableUtils.test.ts create mode 100644 superset-frontend/packages/superset-ui-core/src/utils/withLabel.test.ts diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/utils/metricColumnFilter.test.ts b/superset-frontend/packages/superset-ui-chart-controls/src/utils/metricColumnFilter.test.ts index f6202263a21..a81ea54a623 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/utils/metricColumnFilter.test.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/utils/metricColumnFilter.test.ts @@ -45,6 +45,14 @@ describe('metricColumnFilter', () => { }) as SqlaFormData; describe('shouldSkipMetricColumn', () => { + test('should return false for empty colname', () => { + const colnames = ['metric1', '%metric1']; + const formData = createFormData([], ['metric1']); + expect(shouldSkipMetricColumn({ colname: '', colnames, formData })).toBe( + false, + ); + }); + test('should skip unprefixed percent metric columns if prefixed version exists', () => { const colnames = ['metric1', '%metric1']; const formData = createFormData([], ['metric1']); diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/getColorFormatters.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/utils/getColorFormatters.test.ts index 0c0563049fe..16e4feeb3e7 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/utils/getColorFormatters.test.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/getColorFormatters.test.ts @@ -506,6 +506,19 @@ test('getColorFunction IsNotNull', () => { expect(colorFunction(null)).toBeUndefined(); }); +test('getColorFunction IsNotNull returns undefined for non-boolean value', () => { + const colorFunction = getColorFunction( + { + operator: Comparator.IsNotNull, + targetValue: '', + colorScheme: '#FF0000', + column: 'isMember', + }, + boolValues, + ); + expect(colorFunction(50 as unknown as boolean)).toBeUndefined(); +}); + test('getColorFunction returns undefined for null values on numeric comparators', () => { const operators = [ { operator: Comparator.LessThan, targetValue: 50 }, @@ -805,6 +818,34 @@ test('getColorFormatters with useGradient flag', () => { expect(colorFormatters[1].getColorFromValue(100)).toEqual('#00FF00FF'); }); +test('getColorFunction NOT_EQUAL returns undefined when targetValue is non-numeric', () => { + const colorFunction = getColorFunction( + { + operator: Comparator.NotEqual, + targetValue: 'not-a-number' as unknown as number, + colorScheme: '#FF0000', + column: 'count', + }, + countValues, + ); + expect(colorFunction(50)).toBeUndefined(); + expect(colorFunction(100)).toBeUndefined(); +}); + +test('getColorFormatters resolves colorScheme from theme when it starts with "color"', () => { + const theme = { colorPrimary: '#AABBCC' }; + const columnConfig = [ + { + operator: Comparator.None, + colorScheme: 'colorPrimary', + column: 'count', + }, + ]; + const colorFormatters = getColorFormatters(columnConfig, mockData, theme); + expect(colorFormatters).toHaveLength(1); + expect(colorFormatters[0].getColorFromValue(75)).toContain('#AABBCC'); +}); + test('correct column boolean config', () => { const columnConfigBoolean = [ { diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.test.ts b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.test.ts index 5a66173718f..a2915fef438 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.test.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.test.ts @@ -294,6 +294,126 @@ test('should preserve existing adhoc filters', () => { ); }); +test('should return null when no matrixify configuration exists', () => { + const formData: TestFormData = { + viz_type: 'table', + datasource: '1__table', + // No matrixify_mode_rows or matrixify_mode_columns + }; + + const grid = generateMatrixifyGrid(formData); + expect(grid).toBeNull(); +}); + +test('should generate single-column grid when only rows are configured', () => { + const rowsOnlyFormData: TestFormData = { + viz_type: 'table', + datasource: '1__table', + matrixify_mode_rows: 'metrics', + matrixify_rows: [createAdhocMetric('Revenue'), createAdhocMetric('Profit')], + // No column config + }; + + const grid = generateMatrixifyGrid(rowsOnlyFormData); + expect(grid).not.toBeNull(); + expect(grid!.rowHeaders).toEqual(['Revenue', 'Profit']); + expect(grid!.colHeaders).toEqual(['']); + expect(grid!.cells).toHaveLength(2); + expect(grid!.cells[0]).toHaveLength(1); +}); + +test('should generate single-row grid when only columns are configured', () => { + const colsOnlyFormData: TestFormData = { + viz_type: 'table', + datasource: '1__table', + matrixify_mode_columns: 'metrics', + matrixify_columns: [ + createSqlMetric('Q1', 'SUM(q1)'), + createSqlMetric('Q2', 'SUM(q2)'), + ], + // No row config + }; + + const grid = generateMatrixifyGrid(colsOnlyFormData); + expect(grid).not.toBeNull(); + expect(grid!.rowHeaders).toEqual(['']); + expect(grid!.colHeaders).toEqual(['Q1', 'Q2']); + expect(grid!.cells).toHaveLength(1); + expect(grid!.cells[0]).toHaveLength(2); +}); + +test('should handle invalid Handlebars template gracefully', () => { + const formDataWithBadTemplate: TestFormData = { + ...baseFormData, + matrixify_cell_title_template: '{{#if}}unclosed', + }; + + const grid = generateMatrixifyGrid(formDataWithBadTemplate); + expect(grid).not.toBeNull(); + // Should not throw - returns empty title on template error + const firstCell = grid!.cells[0][0]; + expect(firstCell!.title).toBe(''); +}); + +test('should return empty string header for null metric in array (line 76)', () => { + const formData: TestFormData = { + viz_type: 'table', + datasource: '1__table', + matrixify_mode_rows: 'metrics', + matrixify_mode_columns: 'metrics', + matrixify_rows: [null], + matrixify_columns: [createAdhocMetric('Q1')], + }; + const grid = generateMatrixifyGrid(formData); + expect(grid).not.toBeNull(); + expect(grid!.rowHeaders).toEqual(['']); +}); + +test('should return empty string header for empty-string dimension value (line 86)', () => { + const formData: TestFormData = { + viz_type: 'table', + datasource: '1__table', + matrixify_mode_rows: 'dimensions', + matrixify_mode_columns: 'dimensions', + matrixify_dimension_rows: { dimension: 'country', values: [''] }, + matrixify_dimension_columns: { dimension: 'product', values: ['Widget'] }, + }; + const grid = generateMatrixifyGrid(formData); + expect(grid).not.toBeNull(); + expect(grid!.rowHeaders).toEqual(['']); +}); + +test('should skip dimension filter when value is undefined (lines 151, 165)', () => { + const formData: TestFormData = { + viz_type: 'table', + datasource: '1__table', + matrixify_mode_rows: 'dimensions', + matrixify_mode_columns: 'dimensions', + matrixify_dimension_rows: { + dimension: 'country', + values: [undefined, 'USA'], + }, + matrixify_dimension_columns: { + dimension: 'product', + values: [undefined, 'Widget'], + }, + }; + const grid = generateMatrixifyGrid(formData); + expect(grid).not.toBeNull(); + // Cell at row=0, col=0 has undefined values on both axes — no filters applied + const cell00 = grid!.cells[0][0]; + expect(cell00).toBeDefined(); + expect(cell00!.formData.adhoc_filters ?? []).toEqual([]); + // Cell at row=1, col=1 has defined values — filters applied + const cell11 = grid!.cells[1][1]; + expect(cell11!.formData.adhoc_filters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ subject: 'country', comparator: 'USA' }), + expect.objectContaining({ subject: 'product', comparator: 'Widget' }), + ]), + ); +}); + test('should handle metrics without labels', () => { const metricsWithoutLabels: TestFormData = { viz_type: 'table', diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.ts b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.ts index 7895bf23a25..cc12d396b9e 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.ts @@ -276,10 +276,10 @@ export function generateMatrixifyGrid( const cellFormData = generateCellFormData( formData, - rowCount > 0 ? config.rows : null, - colCount > 0 ? config.columns : null, - rowCount > 0 ? row : null, - colCount > 0 ? col : null, + config.rows, + config.columns, + row, + col, ); // Generate title using template if provided diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.mocks.test.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.mocks.test.ts new file mode 100644 index 00000000000..3fd4d0160d6 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.mocks.test.ts @@ -0,0 +1,28 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 { isMatrixifyEnabled, MatrixifyGridRenderer } from './matrixify.mocks'; + +test('isMatrixifyEnabled mock returns false by default', () => { + expect(isMatrixifyEnabled()).toBe(false); +}); + +test('MatrixifyGridRenderer mock returns null by default', () => { + expect(MatrixifyGridRenderer()).toBeNull(); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.test.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.test.ts index 3ac8fa4b0a3..6fe052a5a74 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.test.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.test.ts @@ -260,6 +260,88 @@ test('should handle empty form data object', () => { expect(isMatrixifyEnabled(formData)).toBe(false); }); +test('isMatrixifyEnabled should return false when layout enabled but no axis modes configured', () => { + const formData = { + viz_type: 'table', + matrixify_enable_vertical_layout: true, + // No matrixify_mode_rows or matrixify_mode_columns set + } as MatrixifyFormData; + expect(isMatrixifyEnabled(formData)).toBe(false); +}); + +test('getMatrixifyValidationErrors should return dimension error for rows when dimension has no data', () => { + const formData = { + viz_type: 'table', + matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'dimensions', + // No matrixify_dimension_rows set + matrixify_mode_columns: 'metrics', + matrixify_columns: [createMetric('Q1')], + } as MatrixifyFormData; + + const errors = getMatrixifyValidationErrors(formData); + expect(errors).toContain('Please select a dimension and values for rows'); +}); + +test('getMatrixifyValidationErrors should return metric error for columns when metrics array is empty', () => { + const formData = { + viz_type: 'table', + matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', + matrixify_rows: [createMetric('Revenue')], + matrixify_mode_columns: 'metrics', + matrixify_columns: [], + } as MatrixifyFormData; + + const errors = getMatrixifyValidationErrors(formData); + expect(errors).toContain('Please select at least one metric for columns'); +}); + +test('getMatrixifyValidationErrors should return dimension error for columns when no dimension data', () => { + const formData = { + viz_type: 'table', + matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', + matrixify_rows: [createMetric('Revenue')], + matrixify_mode_columns: 'dimensions', + // No matrixify_dimension_columns set + } as MatrixifyFormData; + + const errors = getMatrixifyValidationErrors(formData); + expect(errors).toContain('Please select a dimension and values for columns'); +}); + +test('getMatrixifyValidationErrors skips row check when matrixify_mode_rows is not set (line 240 false, line 279 || false)', () => { + const formData = { + viz_type: 'table', + matrixify_enable_vertical_layout: true, + // No matrixify_mode_rows — hasRowMode = false + matrixify_mode_columns: 'metrics', + matrixify_columns: [createMetric('Q1')], + } as MatrixifyFormData; + + const errors = getMatrixifyValidationErrors(formData); + expect(errors).toEqual([]); +}); + +test('getMatrixifyValidationErrors evaluates full && expression when dimension is set but values are empty (lines 244, 264, 283, 291 true branches)', () => { + const formData = { + viz_type: 'table', + matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'dimensions', + matrixify_dimension_rows: { dimension: 'country', values: [] }, + matrixify_mode_columns: 'dimensions', + matrixify_dimension_columns: { dimension: 'product', values: [] }, + } as MatrixifyFormData; + + const errors = getMatrixifyValidationErrors(formData); + expect(errors).toContain('Please select a dimension and values for rows'); + expect(errors).toContain('Please select a dimension and values for columns'); + expect(errors).toContain( + 'Configure at least one complete row or column axis', + ); +}); + test('should handle partial configuration with one axis only', () => { const formData = { viz_type: 'table', diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.ts index e90a6b3a874..8c01651ca60 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.ts @@ -276,28 +276,24 @@ export function getMatrixifyValidationErrors( } // Must have at least one valid axis - if (hasRowMode || hasColumnMode) { - const hasRowData = - config.rows.mode === 'metrics' - ? config.rows.metrics && config.rows.metrics.length > 0 - : config.rows.dimension?.dimension && - (config.rows.selectionMode === 'topn' || - (config.rows.dimension.values && - config.rows.dimension.values.length > 0)); + const hasAnyRowData = + config.rows.mode === 'metrics' + ? config.rows.metrics && config.rows.metrics.length > 0 + : config.rows.dimension?.dimension && + (config.rows.selectionMode === 'topn' || + (config.rows.dimension.values && + config.rows.dimension.values.length > 0)); - const hasColumnData = - config.columns.mode === 'metrics' - ? config.columns.metrics && config.columns.metrics.length > 0 - : config.columns.dimension?.dimension && - (config.columns.selectionMode === 'topn' || - (config.columns.dimension.values && - config.columns.dimension.values.length > 0)); + const hasAnyColumnData = + config.columns.mode === 'metrics' + ? config.columns.metrics && config.columns.metrics.length > 0 + : config.columns.dimension?.dimension && + (config.columns.selectionMode === 'topn' || + (config.columns.dimension.values && + config.columns.dimension.values.length > 0)); - if (!hasRowData && !hasColumnData) { - errors.push('Configure at least one complete row or column axis'); - } - } else { - errors.push('Please configure at least one row or column axis'); + if (!hasAnyRowData && !hasAnyColumnData) { + errors.push('Configure at least one complete row or column axis'); } return errors; diff --git a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/useJsonValidation.test.ts b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/useJsonValidation.test.ts index bab4cc313df..e9179b4ce70 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/useJsonValidation.test.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/useJsonValidation.test.ts @@ -72,4 +72,29 @@ describe('useJsonValidation', () => { expect(result.current[0].text).toContain('Custom error'); }); + + test('falls back to "syntax error" when thrown error has no message (line 59 || branch)', () => { + const spy = jest.spyOn(JSON, 'parse').mockImplementationOnce(() => { + throw {}; // no .message property → error.message is undefined → falsy + }); + + const { result } = renderHook(() => useJsonValidation('some invalid json')); + spy.mockRestore(); + + expect(result.current).toHaveLength(1); + expect(result.current[0].text).toContain('syntax error'); + }); + + test('extracts row and column from error when message contains (line X column Y)', () => { + const spy = jest.spyOn(JSON, 'parse').mockImplementationOnce(() => { + throw new SyntaxError('Unexpected token (line 3 column 5)'); + }); + + const { result } = renderHook(() => useJsonValidation('some invalid json')); + spy.mockRestore(); + + expect(result.current).toHaveLength(1); + expect(result.current[0].row).toBe(2); // 3 - 1 = 2 (0-based) + expect(result.current[0].column).toBe(4); // 5 - 1 = 4 (0-based) + }); }); diff --git a/superset-frontend/packages/superset-ui-core/src/components/List/List.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/List/List.test.tsx index 2423bc3a00f..3c3c53dad42 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/List/List.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/List/List.test.tsx @@ -40,3 +40,13 @@ test('should render the correct number of items', () => { expect(item).toHaveTextContent(`Item ${index + 1}`); }); }); + +test('should render List.Item with compact prop', () => { + const { container } = render(Compact content); + expect(container).toBeInTheDocument(); +}); + +test('should render List.Item without compact prop', () => { + const { container } = render(Regular content); + expect(container).toBeInTheDocument(); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/constants.test.ts b/superset-frontend/packages/superset-ui-core/src/components/Select/constants.test.ts new file mode 100644 index 00000000000..78882c82150 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/constants.test.ts @@ -0,0 +1,49 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 type { LabeledValue as AntdLabeledValue } from 'antd/es/select'; +import { DEFAULT_SORT_COMPARATOR } from './constants'; + +test('DEFAULT_SORT_COMPARATOR sorts by label text when both labels are strings', () => { + const a = { value: 'b', label: 'banana' } as AntdLabeledValue; + const b = { value: 'a', label: 'apple' } as AntdLabeledValue; + expect(DEFAULT_SORT_COMPARATOR(a, b)).toBeGreaterThan(0); + expect(DEFAULT_SORT_COMPARATOR(b, a)).toBeLessThan(0); +}); + +test('DEFAULT_SORT_COMPARATOR sorts by value text when labels are not strings', () => { + const a = { value: 'b' } as AntdLabeledValue; + const b = { value: 'a' } as AntdLabeledValue; + expect(DEFAULT_SORT_COMPARATOR(a, b)).toBeGreaterThan(0); + expect(DEFAULT_SORT_COMPARATOR(b, a)).toBeLessThan(0); +}); + +test('DEFAULT_SORT_COMPARATOR returns numeric difference when values are numbers', () => { + const a = { value: 3 } as unknown as AntdLabeledValue; + const b = { value: 1 } as unknown as AntdLabeledValue; + expect(DEFAULT_SORT_COMPARATOR(a, b)).toBe(2); + expect(DEFAULT_SORT_COMPARATOR(b, a)).toBe(-2); +}); + +test('DEFAULT_SORT_COMPARATOR uses rankedSearchCompare when search is provided', () => { + const a = { value: 'abc', label: 'abc' } as AntdLabeledValue; + const b = { value: 'bc', label: 'bc' } as AntdLabeledValue; + // 'bc' is an exact match to search 'bc', so it should sort first (lower index = negative diff) + expect(DEFAULT_SORT_COMPARATOR(a, b, 'bc')).toBeGreaterThan(0); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/Table/utils/InteractiveTableUtils.test.ts b/superset-frontend/packages/superset-ui-core/src/components/Table/utils/InteractiveTableUtils.test.ts new file mode 100644 index 00000000000..827a87a6007 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/Table/utils/InteractiveTableUtils.test.ts @@ -0,0 +1,574 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 { SUPERSET_TABLE_COLUMN } from '..'; +import InteractiveTableUtils from './InteractiveTableUtils'; + +const mockColumns = [ + { key: 'name', dataIndex: 'name', title: 'Name' }, + { key: 'age', dataIndex: 'age', title: 'Age' }, +]; + +const createMockTable = (numCols = 2): HTMLTableElement => { + const table = document.createElement('table'); + const thead = document.createElement('thead'); + const tr = document.createElement('tr'); + for (let i = 0; i < numCols; i += 1) { + const th = document.createElement('th'); + tr.appendChild(th); + } + thead.appendChild(tr); + table.appendChild(thead); + document.body.appendChild(table); + return table; +}; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +test('constructor initializes with correct defaults', () => { + const table = createMockTable(); + const setDerivedColumns = jest.fn(); + const utils = new InteractiveTableUtils( + table, + mockColumns, + setDerivedColumns, + ); + + expect(utils.tableRef).toBe(table); + expect(utils.isDragging).toBe(false); + expect(utils.resizable).toBe(false); + expect(utils.reorderable).toBe(false); + expect(utils.derivedColumns).toEqual(mockColumns); + expect(utils.RESIZE_INDICATOR_THRESHOLD).toBe(8); +}); + +test('setTableRef updates tableRef', () => { + const table = createMockTable(); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + const newTable = createMockTable(); + utils.setTableRef(newTable); + expect(utils.tableRef).toBe(newTable); +}); + +test('getColumnIndex returns -1 when columnRef has no parent', () => { + const table = createMockTable(); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + utils.columnRef = null; + expect(utils.getColumnIndex()).toBe(-1); +}); + +test('getColumnIndex returns correct index when columnRef is in a row', () => { + const table = createMockTable(3); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + const row = table.rows[0]; + utils.columnRef = row.cells[1] as unknown as typeof utils.columnRef; + expect(utils.getColumnIndex()).toBe(1); +}); + +test('allowDrop calls preventDefault on the event', () => { + const table = createMockTable(); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + const event = { preventDefault: jest.fn() } as unknown as DragEvent; + utils.allowDrop(event); + expect(event.preventDefault).toHaveBeenCalledTimes(1); +}); + +test('handleMouseup clears mouseDown and resets dragging state', () => { + const table = createMockTable(); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + const th = document.createElement('th') as unknown as typeof utils.columnRef; + utils.columnRef = th; + (th as any).mouseDown = true; + utils.isDragging = true; + + utils.handleMouseup(); + + expect((th as any).mouseDown).toBe(false); + expect(utils.isDragging).toBe(false); +}); + +test('handleMouseup works when columnRef is null', () => { + const table = createMockTable(); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + utils.columnRef = null; + utils.isDragging = true; + + utils.handleMouseup(); + + expect(utils.isDragging).toBe(false); +}); + +test('handleMouseDown sets mouseDown and oldX when within resize range', () => { + const table = createMockTable(); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + const target = document.createElement('th') as any; + Object.defineProperty(target, 'offsetWidth', { + value: 100, + configurable: true, + }); + + const event = { + currentTarget: target, + offsetX: 95, // 100 - 95 = 5, within threshold of 8 + x: 95, + } as unknown as MouseEvent; + + utils.handleMouseDown(event); + + expect(target.mouseDown).toBe(true); + expect(target.oldX).toBe(95); + expect(target.oldWidth).toBe(100); + expect(target.draggable).toBe(false); +}); + +test('handleMouseDown sets draggable when outside resize range and reorderable', () => { + const table = createMockTable(); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + utils.reorderable = true; + + const target = document.createElement('th') as any; + Object.defineProperty(target, 'offsetWidth', { + value: 100, + configurable: true, + }); + + const event = { + currentTarget: target, + offsetX: 50, // 100 - 50 = 50, outside threshold of 8 + x: 50, + } as unknown as MouseEvent; + + utils.handleMouseDown(event); + + expect(target.draggable).toBe(true); +}); + +test('initializeResizableColumns adds event listeners when resizable is true', () => { + const table = createMockTable(2); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + const cell = table.rows[0].cells[0]; + const addEventSpy = jest.spyOn(cell, 'addEventListener'); + + utils.initializeResizableColumns(true, table); + + expect(utils.resizable).toBe(true); + expect(addEventSpy).toHaveBeenCalledWith('mousedown', utils.handleMouseDown); + expect(addEventSpy).toHaveBeenCalledWith( + 'mousemove', + utils.handleMouseMove, + true, + ); +}); + +test('initializeResizableColumns removes event listeners when resizable is false', () => { + const table = createMockTable(2); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + const cell = table.rows[0].cells[0]; + const removeEventSpy = jest.spyOn(cell, 'removeEventListener'); + + utils.initializeResizableColumns(false, table); + + expect(utils.resizable).toBe(false); + expect(removeEventSpy).toHaveBeenCalledWith( + 'mousedown', + utils.handleMouseDown, + ); + expect(removeEventSpy).toHaveBeenCalledWith( + 'mousemove', + utils.handleMouseMove, + true, + ); +}); + +test('initializeDragDropColumns adds event listeners when reorderable is true', () => { + const table = createMockTable(2); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + const cell = table.rows[0].cells[0]; + const addEventSpy = jest.spyOn(cell, 'addEventListener'); + + utils.initializeDragDropColumns(true, table); + + expect(utils.reorderable).toBe(true); + expect(addEventSpy).toHaveBeenCalledWith( + 'dragstart', + utils.handleColumnDragStart, + ); + expect(addEventSpy).toHaveBeenCalledWith('drop', utils.handleDragDrop); +}); + +test('initializeDragDropColumns removes event listeners when reorderable is false', () => { + const table = createMockTable(2); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + const cell = table.rows[0].cells[0]; + const removeEventSpy = jest.spyOn(cell, 'removeEventListener'); + + utils.initializeDragDropColumns(false, table); + + expect(utils.reorderable).toBe(false); + expect(removeEventSpy).toHaveBeenCalledWith( + 'dragstart', + utils.handleColumnDragStart, + ); + expect(removeEventSpy).toHaveBeenCalledWith('drop', utils.handleDragDrop); +}); + +test('handleColumnDragStart sets isDragging and calls setData', () => { + const table = createMockTable(2); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + + const row = table.rows[0]; + const target = row.cells[0] as any; + const setDataMock = jest.fn(); + const event = { + currentTarget: target, + dataTransfer: { setData: setDataMock }, + } as unknown as DragEvent; + + utils.handleColumnDragStart(event); + + expect(utils.isDragging).toBe(true); + expect(setDataMock).toHaveBeenCalledWith( + SUPERSET_TABLE_COLUMN, + expect.any(String), + ); +}); + +test('handleDragDrop reorders columns when valid drag data exists', () => { + const table = createMockTable(2); + const setDerivedColumns = jest.fn(); + const utils = new InteractiveTableUtils( + table, + mockColumns, + setDerivedColumns, + ); + + const row = table.rows[0]; + // Set columnRef to first column (drag source) + utils.columnRef = row.cells[0] as unknown as typeof utils.columnRef; + + const dragData = JSON.stringify({ index: 0, columnData: mockColumns[0] }); + const dropTarget = row.cells[1]; + const event = { + currentTarget: dropTarget, + dataTransfer: { getData: jest.fn().mockReturnValue(dragData) }, + preventDefault: jest.fn(), + } as unknown as DragEvent; + + utils.handleDragDrop(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(setDerivedColumns).toHaveBeenCalledTimes(1); +}); + +test('handleDragDrop does nothing when no drag data', () => { + const table = createMockTable(2); + const setDerivedColumns = jest.fn(); + const utils = new InteractiveTableUtils( + table, + mockColumns, + setDerivedColumns, + ); + + const row = table.rows[0]; + const event = { + currentTarget: row.cells[0], + dataTransfer: { getData: jest.fn().mockReturnValue('') }, + preventDefault: jest.fn(), + } as unknown as DragEvent; + + utils.handleDragDrop(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(setDerivedColumns).not.toHaveBeenCalled(); +}); + +test('handleMouseMove updates cursor to col-resize when within resize range', () => { + const table = createMockTable(2); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + utils.resizable = true; + + const target = document.createElement('th') as any; + Object.defineProperty(target, 'offsetWidth', { + value: 100, + configurable: true, + }); + target.style = { cursor: '' }; + + const event = { + currentTarget: target, + offsetX: 95, + x: 0, + } as unknown as MouseEvent; + + utils.handleMouseMove(event); + + expect(target.style.cursor).toBe('col-resize'); +}); + +test('handleMouseMove sets default cursor when outside resize range', () => { + const table = createMockTable(2); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + utils.resizable = true; + + const target = document.createElement('th') as any; + Object.defineProperty(target, 'offsetWidth', { + value: 100, + configurable: true, + }); + target.style = { cursor: '' }; + + const event = { + currentTarget: target, + offsetX: 50, + x: 0, + } as unknown as MouseEvent; + + utils.handleMouseMove(event); + + expect(target.style.cursor).toBe('default'); +}); + +test('handleMouseMove resizes column when mouseDown and within bounds', () => { + const table = createMockTable(2); + const setDerivedColumns = jest.fn(); + const utils = new InteractiveTableUtils( + table, + mockColumns, + setDerivedColumns, + ); + utils.resizable = true; + + const row = table.rows[0]; + const col = row.cells[0] as any; + col.mouseDown = true; + col.oldWidth = 100; + col.oldX = 50; + utils.columnRef = col; + + const target = document.createElement('th') as any; + Object.defineProperty(target, 'offsetWidth', { + value: 100, + configurable: true, + }); + target.style = { cursor: '' }; + + const event = { + currentTarget: target, + offsetX: 50, + x: 70, // diff = 70 - 50 = 20, width = 100 + 20 = 120 + } as unknown as MouseEvent; + + utils.handleMouseMove(event); + + expect(setDerivedColumns).toHaveBeenCalledTimes(1); + expect(utils.derivedColumns[0].width).toBe(120); +}); + +test('handleMouseMove skips resize when not resizable', () => { + const table = createMockTable(2); + const setDerivedColumns = jest.fn(); + const utils = new InteractiveTableUtils( + table, + mockColumns, + setDerivedColumns, + ); + utils.resizable = false; + + const target = document.createElement('th') as any; + const event = { + currentTarget: target, + offsetX: 50, + x: 70, + } as unknown as MouseEvent; + + utils.handleMouseMove(event); + + expect(setDerivedColumns).not.toHaveBeenCalled(); +}); + +test('handleMouseMove handles negative diff by keeping original width', () => { + const table = createMockTable(2); + const setDerivedColumns = jest.fn(); + const utils = new InteractiveTableUtils( + table, + mockColumns, + setDerivedColumns, + ); + utils.resizable = true; + + const row = table.rows[0]; + const col = row.cells[0] as any; + col.mouseDown = true; + col.oldWidth = 50; + col.oldX = 200; + utils.columnRef = col; + + const target = document.createElement('th') as any; + Object.defineProperty(target, 'offsetWidth', { + value: 50, + configurable: true, + }); + target.style = { cursor: '' }; + + const event = { + currentTarget: target, + offsetX: 45, + x: 0, // diff = 0 - 200 = -200, width would be 50 + (-200) = -150 < 0 → keep 50 + } as unknown as MouseEvent; + + utils.handleMouseMove(event); + + expect(setDerivedColumns).toHaveBeenCalledTimes(1); + expect(utils.derivedColumns[0].width).toBe(50); // unchanged because negative would result +}); + +test('handleColumnDragStart does not set columnRef when currentTarget is null (line 82 false)', () => { + const table = createMockTable(2); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + + const event = { + currentTarget: null, + dataTransfer: { setData: jest.fn() }, + } as unknown as DragEvent; + + utils.handleColumnDragStart(event); + + expect(utils.isDragging).toBe(true); + expect(utils.columnRef).toBeFalsy(); +}); + +test('handleMouseDown does nothing when currentTarget is null (line 118 false)', () => { + const table = createMockTable(); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + + const event = { + currentTarget: null, + offsetX: 50, + x: 50, + } as unknown as MouseEvent; + + utils.handleMouseDown(event); + + expect(utils.columnRef).toBeFalsy(); +}); + +test('handleMouseDown does nothing to draggable when outside resize range and not reorderable (line 132 false)', () => { + const table = createMockTable(); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + utils.reorderable = false; + + const target = document.createElement('th') as any; + Object.defineProperty(target, 'offsetWidth', { + value: 100, + configurable: true, + }); + + const event = { + currentTarget: target, + offsetX: 50, // 100 - 50 = 50, outside threshold of 8 + x: 50, + } as unknown as MouseEvent; + + utils.handleMouseDown(event); + + expect(target.draggable).toBe(false); +}); + +test('handleMouseMove skips column update when getColumnIndex returns NaN (line 162 false)', () => { + const table = createMockTable(2); + const setDerivedColumns = jest.fn(); + const utils = new InteractiveTableUtils( + table, + mockColumns, + setDerivedColumns, + ); + utils.resizable = true; + + const row = table.rows[0]; + const col = row.cells[0] as any; + col.mouseDown = true; + col.oldWidth = 100; + col.oldX = 50; + utils.columnRef = col; + + jest.spyOn(utils, 'getColumnIndex').mockReturnValueOnce(NaN); + + const target = document.createElement('th') as any; + Object.defineProperty(target, 'offsetWidth', { + value: 100, + configurable: true, + }); + target.style = { cursor: '' }; + + const event = { + currentTarget: target, + offsetX: 50, + x: 70, + } as unknown as MouseEvent; + + utils.handleMouseMove(event); + + expect(setDerivedColumns).not.toHaveBeenCalled(); +}); + +test('initializeResizableColumns does nothing when table is null (lines 182-187 false)', () => { + const table = createMockTable(); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + + expect(() => utils.initializeResizableColumns(true, null)).not.toThrow(); + expect(utils.tableRef).toBeNull(); +}); + +test('initializeResizableColumns uses default resizable=false when first arg is undefined (line 182 default branch)', () => { + const table = createMockTable(2); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + + utils.initializeResizableColumns(undefined, table); + + expect(utils.resizable).toBe(false); +}); + +test('initializeDragDropColumns does nothing when table is null (lines 206-211 false)', () => { + const table = createMockTable(); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + + expect(() => utils.initializeDragDropColumns(true, null)).not.toThrow(); + expect(utils.tableRef).toBeNull(); +}); + +test('initializeDragDropColumns uses default reorderable=false when first arg is undefined (line 206 default branch)', () => { + const table = createMockTable(2); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + + utils.initializeDragDropColumns(undefined, table); + + expect(utils.reorderable).toBe(false); +}); + +test('clearListeners removes document mouseup listener', () => { + const table = createMockTable(); + const utils = new InteractiveTableUtils(table, mockColumns, jest.fn()); + const removeEventSpy = jest.spyOn(document, 'removeEventListener'); + + utils.clearListeners(); + + expect(removeEventSpy).toHaveBeenCalledWith('mouseup', utils.handleMouseup); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/types/react-syntax-highlighter.d.ts b/superset-frontend/packages/superset-ui-core/src/types/react-syntax-highlighter.d.ts index f0860387ddf..ad0705a20b1 100644 --- a/superset-frontend/packages/superset-ui-core/src/types/react-syntax-highlighter.d.ts +++ b/superset-frontend/packages/superset-ui-core/src/types/react-syntax-highlighter.d.ts @@ -25,10 +25,3 @@ declare module 'react-syntax-highlighter/dist/cjs/styles/hljs/github' { const style: any; export default style; } - -type SupportedLanguages = 'markdown' | 'htmlbars' | 'sql' | 'json'; - -// For type checking when importing languages -function importLanguage(language: T) { - return import(`react-syntax-highlighter/dist/cjs/languages/hljs/${language}`); -} diff --git a/superset-frontend/packages/superset-ui-core/src/utils/rankedSearchCompare.test.ts b/superset-frontend/packages/superset-ui-core/src/utils/rankedSearchCompare.test.ts index 268511a5cba..a72f3e25024 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/rankedSearchCompare.test.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/rankedSearchCompare.test.ts @@ -46,3 +46,23 @@ test('Sort starts with first', async () => { test('Sort same case first', async () => { expect(['%f %B', '%F %b'].sort(searchSort('%F'))).toEqual(['%F %b', '%f %B']); }); + +test('returns localeCompare result when no search term provided', () => { + expect(rankedSearchCompare('banana', 'apple', '')).toBeGreaterThan(0); + expect(rankedSearchCompare('apple', 'banana', '')).toBeLessThan(0); +}); + +test('handles empty string a', () => { + const result = rankedSearchCompare('', 'hello', 'hello'); + expect(typeof result).toBe('number'); +}); + +test('handles empty string b', () => { + const result = rankedSearchCompare('hello', '', 'hello'); + expect(typeof result).toBe('number'); +}); + +test('falls back to localeCompare when strings have no match relationship to search', () => { + expect(rankedSearchCompare('abc', 'def', 'xyz')).toBeLessThan(0); + expect(rankedSearchCompare('def', 'abc', 'xyz')).toBeGreaterThan(0); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/utils/withLabel.test.ts b/superset-frontend/packages/superset-ui-core/src/utils/withLabel.test.ts new file mode 100644 index 00000000000..67e51eb2c93 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/utils/withLabel.test.ts @@ -0,0 +1,39 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 withLabel from './withLabel'; + +test('withLabel returns false when validator passes', () => { + const validator = () => false as false; + const labeled = withLabel(validator, 'Field'); + expect(labeled('any value')).toBe(false); +}); + +test('withLabel prepends label to validator error message', () => { + const validator = () => 'is required'; + const labeled = withLabel(validator, 'Name'); + expect(labeled('')).toBe('Name is required'); +}); + +test('withLabel passes value and state to underlying validator', () => { + const validator = jest.fn(() => false as false); + const labeled = withLabel(validator, 'Field'); + labeled('value', { someState: true }); + expect(validator).toHaveBeenCalledWith('value', { someState: true }); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts index 92efac60892..cdfe2dacd98 100644 --- a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts @@ -144,4 +144,10 @@ describe('SupersetClient', () => { fetchMock.clearHistory().removeRoutes(); }); + + test('getCSRFToken() returns existing token when already configured', async () => { + SupersetClient.configure({ csrfToken: 'my_token' }); + const token = await SupersetClient.getCSRFToken(); + expect(token).toBe('my_token'); + }); }); diff --git a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts index e5020b20812..bc9448be4e0 100644 --- a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts @@ -108,6 +108,20 @@ describe('SupersetClientClass', () => { expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(0); }); + test('getCSRFToken() returns existing csrfToken without fetching when already set', async () => { + const client = new SupersetClientClass({ csrfToken: 'existing_token' }); + const token = await client.getCSRFToken(); + expect(token).toBe('existing_token'); + expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(0); + }); + + test('getCSRFToken() calls fetchCSRFToken when csrfToken is not set (line 261 || branch)', async () => { + const client = new SupersetClientClass({}); + const token = await client.getCSRFToken(); + expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(1); + expect(token).toBe(1234); + }); + test('calls api/v1/security/csrf_token/ when init(force=true) is called even if a CSRF token is passed', async () => { expect.assertions(4); const initialToken = 'initial_token'; @@ -156,6 +170,25 @@ describe('SupersetClientClass', () => { } }); + test('does not set csrfToken when json response is a non-object primitive (line 245 false branch)', async () => { + expect.assertions(1); + fetchMock.removeRoute(LOGIN_GLOB); + // String '123' is used as raw body text; response.json() parses it to the + // number 123, so typeof json === 'object' is false + fetchMock.get(LOGIN_GLOB, '123', { name: LOGIN_GLOB }); + + let error; + try { + await new SupersetClientClass({}).init(); + } catch (err) { + error = err; + } finally { + expect(error as typeof invalidCsrfTokenError).toEqual( + invalidCsrfTokenError, + ); + } + }); + test('does not set csrfToken if response is not json', async () => { expect.assertions(1); fetchMock.removeRoute(LOGIN_GLOB); diff --git a/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts b/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts index 251ecdded09..1b27d0d0d7a 100644 --- a/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts @@ -187,3 +187,81 @@ test('CurrencyFormatter gracefully handles invalid currency code', () => { // Should not throw, should return formatted value without currency symbol expect(formatter.format(1000)).toBe('1,000.00'); }); + +test('CurrencyFormatter AUTO mode uses suffix position from row context', () => { + const formatter = new CurrencyFormatter({ + currency: { symbol: 'AUTO', symbolPosition: 'suffix' }, + d3Format: ',.2f', + }); + + const row = { currency: 'EUR' }; + const result = formatter.format(1000, row, 'currency'); + expect(result).toContain('€'); + expect(result).toMatch(/1,000\.00.*€/); +}); + +test('CurrencyFormatter AUTO mode uses default suffix when symbolPosition is unknown', () => { + const formatter = new CurrencyFormatter({ + // @ts-expect-error + currency: { symbol: 'AUTO' }, + d3Format: ',.2f', + }); + + const row = { currency: 'EUR' }; + const result = formatter.format(1000, row, 'currency'); + expect(result).toContain('€'); + expect(result).toMatch(/1,000\.00.*€/); +}); + +test('CurrencyFormatter AUTO mode returns plain value when row currency is not a string (line 52)', () => { + const formatter = new CurrencyFormatter({ + currency: { symbol: 'AUTO', symbolPosition: 'prefix' }, + d3Format: ',.2f', + }); + + // Passing a numeric currency value causes normalizeCurrency to hit + // `typeof value !== 'string'` → return null, so no symbol is appended + const row = { currency: 123 }; + expect(formatter.format(1000, row as any, 'currency')).toBe('1,000.00'); +}); + +test('CurrencyFormatter AUTO mode returns plain value when getCurrencySymbol returns undefined (line 126 false branch)', () => { + const formatter = new CurrencyFormatter({ + currency: { symbol: 'AUTO', symbolPosition: 'prefix' }, + d3Format: ',.2f', + }); + + const OrigNumberFormat = Intl.NumberFormat; + // Return formatToParts without a 'currency' entry so getCurrencySymbol → undefined + Intl.NumberFormat = jest.fn().mockImplementation(() => ({ + formatToParts: () => [{ type: 'integer', value: '1' }], + })) as unknown as typeof Intl.NumberFormat; + + const row = { currency: 'EUR' }; + const result = formatter.format(1000, row, 'currency'); + + Intl.NumberFormat = OrigNumberFormat; + + expect(result).toBe('1,000.00'); +}); + +test('CurrencyFormatter AUTO mode falls back to plain value when getCurrencySymbol throws', () => { + const formatter = new CurrencyFormatter({ + currency: { symbol: 'AUTO', symbolPosition: 'prefix' }, + d3Format: ',.2f', + }); + + // Mock Intl.NumberFormat to throw to simulate an environment where the + // currency code is rejected, triggering the catch block in format() + const OrigNumberFormat = Intl.NumberFormat; + Intl.NumberFormat = jest.fn().mockImplementation(() => { + throw new RangeError('Invalid currency code'); + }) as unknown as typeof Intl.NumberFormat; + + const row = { currency: 'ZZZ' }; + const result = formatter.format(1000, row, 'currency'); + + Intl.NumberFormat = OrigNumberFormat; + + expect(result).toBe('1,000.00'); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/currency-format/utils.test.ts b/superset-frontend/packages/superset-ui-core/test/currency-format/utils.test.ts index ef8f0ee3d15..facf7183806 100644 --- a/superset-frontend/packages/superset-ui-core/test/currency-format/utils.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/utils.test.ts @@ -27,6 +27,10 @@ import { NumberFormatter, ValueFormatter, } from '@superset-ui/core'; +import { + analyzeCurrencyInData, + resolveAutoCurrency, +} from '../../src/currency-format/utils'; test('buildCustomFormatters without saved metrics returns empty object', () => { expect( @@ -219,3 +223,192 @@ test('getValueFormatter return NumberFormatter when no currency formatters', () ); expect(formatter).toBeInstanceOf(NumberFormatter); }); + +test('analyzeCurrencyInData returns null when all currency values are null/undefined', () => { + const data = [ + { value: 100, currency: null }, + { value: 200, currency: undefined }, + ]; + expect(analyzeCurrencyInData(data as any, 'currency')).toBeNull(); +}); + +test('analyzeCurrencyInData returns null when currencyColumn is not provided', () => { + expect(analyzeCurrencyInData([{ value: 100 }], undefined)).toBeNull(); +}); + +test('analyzeCurrencyInData returns detected currency for consistent values', () => { + const data = [ + { value: 100, currency: 'USD' }, + { value: 200, currency: 'USD' }, + ]; + expect(analyzeCurrencyInData(data, 'currency')).toBe('USD'); +}); + +test('resolveAutoCurrency returns currencyFormat unchanged when not AUTO', () => { + const currency: Currency = { symbol: 'USD', symbolPosition: 'prefix' }; + expect(resolveAutoCurrency(currency, null)).toEqual(currency); +}); + +test('resolveAutoCurrency returns currency from backendDetected when AUTO', () => { + const currency: Currency = { symbol: 'AUTO', symbolPosition: 'prefix' }; + const result = resolveAutoCurrency(currency, 'EUR'); + expect(result).toEqual({ symbol: 'EUR', symbolPosition: 'prefix' }); +}); + +test('resolveAutoCurrency returns null when AUTO and no detection source', () => { + const currency: Currency = { symbol: 'AUTO', symbolPosition: 'prefix' }; + expect(resolveAutoCurrency(currency, null)).toBeNull(); +}); + +test('resolveAutoCurrency detects currency from data when backendDetected is undefined', () => { + const currency: Currency = { symbol: 'AUTO', symbolPosition: 'suffix' }; + const data = [ + { value: 100, cur: 'JPY' }, + { value: 200, cur: 'JPY' }, + ]; + const result = resolveAutoCurrency(currency, undefined, data, 'cur'); + expect(result).toEqual({ symbol: 'JPY', symbolPosition: 'suffix' }); +}); + +test('resolveAutoCurrency returns null when data analysis finds mixed currencies', () => { + const currency: Currency = { symbol: 'AUTO', symbolPosition: 'prefix' }; + const data = [{ cur: 'USD' }, { cur: 'EUR' }]; + const result = resolveAutoCurrency(currency, undefined, data, 'cur'); + expect(result).toBeNull(); +}); + +test('buildCustomFormatters with AUTO currency and data resolves currency', () => { + const data = [{ metric: 1, currency: 'EUR' }]; + const result = buildCustomFormatters( + ['metric'], + {}, + {}, + ',.2f', + { symbol: 'AUTO', symbolPosition: 'prefix' }, + data, + 'currency', + ) as Record; + expect(result).toHaveProperty('metric'); + expect(result.metric).toBeInstanceOf(CurrencyFormatter); +}); + +test('buildCustomFormatters with AUTO currency and no detected currency returns NumberFormatter', () => { + // Mixed currencies → null resolved format → NumberFormatter + const data = [ + { metric: 1, currency: 'USD' }, + { metric: 2, currency: 'EUR' }, + ]; + const result = buildCustomFormatters( + ['metric'], + {}, + {}, + ',.2f', + { symbol: 'AUTO', symbolPosition: 'prefix' }, + data, + 'currency', + ) as Record; + expect(result).toHaveProperty('metric'); + expect(result.metric).toBeInstanceOf(NumberFormatter); +}); + +test('getValueFormatter with AUTO currency and detectedCurrency provided', () => { + const formatter = getValueFormatter( + ['count'], + {}, + {}, + ',.1f', + { symbol: 'AUTO', symbolPosition: 'prefix' }, + 'count', + undefined, + undefined, + 'USD', + ); + expect(formatter).toBeInstanceOf(CurrencyFormatter); +}); + +test('getValueFormatter with AUTO currency and null detectedCurrency returns NumberFormatter', () => { + const formatter = getValueFormatter( + ['count'], + {}, + {}, + ',.1f', + { symbol: 'AUTO', symbolPosition: 'prefix' }, + 'count', + undefined, + undefined, + null, + ); + expect(formatter).toBeInstanceOf(NumberFormatter); +}); + +test('getValueFormatter with AUTO currency and data + currencyCodeColumn', () => { + const data = [ + { count: 100, currency: 'GBP' }, + { count: 200, currency: 'GBP' }, + ]; + const formatter = getValueFormatter( + ['count'], + {}, + {}, + ',.1f', + { symbol: 'AUTO', symbolPosition: 'suffix' }, + 'count', + data, + 'currency', + ); + expect(formatter).toBeInstanceOf(CurrencyFormatter); +}); + +test('getValueFormatter with AUTO currency, data+column but mixed currencies falls back to NumberFormatter (line 178 false branch)', () => { + // Mixed currencies → analyzeCurrencyInData returns null → frontendDetected falsy + // → resolvedCurrencyFormat = null (the ternary false branch at line 178) + const data = [ + { count: 100, currency: 'USD' }, + { count: 200, currency: 'EUR' }, + ]; + const formatter = getValueFormatter( + ['count'], + {}, + {}, + ',.1f', + { symbol: 'AUTO', symbolPosition: 'prefix' }, + 'count', + data, + 'currency', + ); + expect(formatter).toBeInstanceOf(NumberFormatter); +}); + +test('getValueFormatter with AUTO currency and no data falls back to NumberFormatter', () => { + const formatter = getValueFormatter( + ['count'], + {}, + {}, + ',.1f', + { symbol: 'AUTO', symbolPosition: 'prefix' }, + 'count', + ); + expect(formatter).toBeInstanceOf(NumberFormatter); +}); + +test('getValueFormatter returns NumberFormatter via line 205 when AUTO resolves to null and metrics are all adhoc', () => { + // String metrics produce a NumberFormatter entry in buildCustomFormatters, + // making customFormatter truthy and bypassing line 205. Adhoc metric objects + // are skipped by buildCustomFormatters, so customFormatter stays undefined, + // and the resolvedCurrencyFormat === null branch at line 205 is reached. + const adhocMetric = { + expressionType: 'SIMPLE' as const, + aggregate: 'COUNT' as const, + column: { column_name: 'test' }, + }; + const formatter = getValueFormatter( + [adhocMetric], + {}, + {}, + ',.1f', + { symbol: 'AUTO', symbolPosition: 'prefix' }, + 'some_key', + undefined, // no data → else branch → resolvedCurrencyFormat = null + ); + expect(formatter).toBeInstanceOf(NumberFormatter); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/query/types/Column.test.ts b/superset-frontend/packages/superset-ui-core/test/query/types/Column.test.ts index d4391cfd019..e9408021975 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/types/Column.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/types/Column.test.ts @@ -18,6 +18,7 @@ */ import { isAdhocColumn, + isAdhocColumnReference, isPhysicalColumn, isQueryFormColumn, } from '@superset-ui/core'; @@ -61,3 +62,16 @@ test('isQueryFormColumn returns true', () => { test('isQueryFormColumn returns false', () => { expect(isQueryFormColumn({})).toEqual(false); }); + +test('isAdhocColumnReference returns true for adhoc column with isColumnReference', () => { + const ref = { ...adhocColumn, isColumnReference: true }; + expect(isAdhocColumnReference(ref)).toEqual(true); +}); + +test('isAdhocColumnReference returns false for non-reference adhoc column', () => { + expect(isAdhocColumnReference(adhocColumn)).toEqual(false); +}); + +test('isAdhocColumnReference returns false for non-adhoc column', () => { + expect(isAdhocColumnReference('gender')).toEqual(false); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/query/types/Dashboard.test.ts b/superset-frontend/packages/superset-ui-core/test/query/types/Dashboard.test.ts index c1c71439577..b34a4174207 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/types/Dashboard.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/types/Dashboard.test.ts @@ -28,6 +28,11 @@ import { isAppliedNativeFilterType, AppliedCrossFilterType, AppliedNativeFilterType, + isChartCustomization, + isChartCustomizationDivider, + ChartCustomization, + ChartCustomizationDivider, + ChartCustomizationType, } from '@superset-ui/core'; const filter: Filter = { @@ -96,3 +101,31 @@ test('applied native filter type guard', () => { expect(isAppliedNativeFilterType(appliedNativeFilter)).toBeTruthy(); expect(isAppliedNativeFilterType(appliedCrossFilter)).toBeFalsy(); }); + +const chartCustomization: ChartCustomization = { + id: 'custom_id', + type: ChartCustomizationType.ChartCustomization, + name: 'My Customization', + filterType: 'chart_customization', + targets: [], + scope: { rootPath: [], excluded: [] }, + defaultDataMask: {}, + controlValues: {}, +}; + +const chartCustomizationDivider: ChartCustomizationDivider = { + id: 'divider_id', + type: ChartCustomizationType.Divider, + title: 'Divider', + description: 'A divider', +}; + +test('isChartCustomization type guard', () => { + expect(isChartCustomization(chartCustomization)).toBeTruthy(); + expect(isChartCustomization(filter)).toBeFalsy(); +}); + +test('isChartCustomizationDivider type guard', () => { + expect(isChartCustomizationDivider(chartCustomizationDivider)).toBeTruthy(); + expect(isChartCustomizationDivider(chartCustomization)).toBeFalsy(); +});