mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
1163 lines
38 KiB
TypeScript
1163 lines
38 KiB
TypeScript
/**
|
|
* 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 { ReactElement } from 'react';
|
|
import '@testing-library/jest-dom';
|
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
|
|
import { TableRenderer } from '../../src/react-pivottable/TableRenderers';
|
|
import {
|
|
aggregatorTemplates,
|
|
groupingValueSort,
|
|
PivotData,
|
|
} from '../../src/react-pivottable/utilities';
|
|
|
|
jest.mock(
|
|
'react-icons/fa',
|
|
() => ({
|
|
FaSort: () => <span data-testid="sort-icon" />,
|
|
FaSortDown: () => <span data-testid="sort-desc-icon" />,
|
|
FaSortUp: () => <span data-testid="sort-asc-icon" />,
|
|
}),
|
|
{ virtual: true },
|
|
);
|
|
|
|
/**
|
|
* A minimal aggregatorsFactory that mirrors the production one.
|
|
* PivotData's constructor calls `aggregatorsFactory(defaultFormatter)`
|
|
* to obtain a map of aggregator constructors keyed by name.
|
|
* The `formatter` argument is ignored here because the tests only
|
|
* care about rendering output, not number formatting precision.
|
|
*/
|
|
const aggregatorsFactory = () => ({
|
|
Count: aggregatorTemplates.count(),
|
|
Sum: aggregatorTemplates.sum(),
|
|
});
|
|
|
|
const SAMPLE_DATA = [
|
|
{ color: 'blue', shape: 'circle', value: 10 },
|
|
{ color: 'blue', shape: 'square', value: 20 },
|
|
{ color: 'red', shape: 'circle', value: 30 },
|
|
{ color: 'red', shape: 'square', value: 40 },
|
|
];
|
|
|
|
/**
|
|
* Pre-aggregated, per-level data matching the multi-query contract: every record
|
|
* is tagged with the rollup level (rows/columns) that produced it, and the
|
|
* database has already computed each value. PivotData places these verbatim.
|
|
* Here `value` is a count, so leaf cells are 1, row/col totals are 2, grand
|
|
* total is 4.
|
|
*/
|
|
const TAGGED_COUNT_DATA = [
|
|
// leaf cells (full detail)
|
|
{
|
|
color: 'blue',
|
|
shape: 'circle',
|
|
value: 1,
|
|
__rows: ['color'],
|
|
__columns: ['shape'],
|
|
},
|
|
{
|
|
color: 'blue',
|
|
shape: 'square',
|
|
value: 1,
|
|
__rows: ['color'],
|
|
__columns: ['shape'],
|
|
},
|
|
{
|
|
color: 'red',
|
|
shape: 'circle',
|
|
value: 1,
|
|
__rows: ['color'],
|
|
__columns: ['shape'],
|
|
},
|
|
{
|
|
color: 'red',
|
|
shape: 'square',
|
|
value: 1,
|
|
__rows: ['color'],
|
|
__columns: ['shape'],
|
|
},
|
|
// row totals (per color, across shapes)
|
|
{ color: 'blue', value: 2, __rows: ['color'], __columns: [] },
|
|
{ color: 'red', value: 2, __rows: ['color'], __columns: [] },
|
|
// col totals (per shape, across colors)
|
|
{ shape: 'circle', value: 2, __rows: [], __columns: ['shape'] },
|
|
{ shape: 'square', value: 2, __rows: [], __columns: ['shape'] },
|
|
// grand total
|
|
{ value: 4, __rows: [], __columns: [] },
|
|
];
|
|
|
|
function renderWithTheme(ui: ReactElement) {
|
|
return render(<ThemeProvider theme={supersetTheme}>{ui}</ThemeProvider>);
|
|
}
|
|
|
|
function buildDefaultProps(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
data: SAMPLE_DATA,
|
|
rows: ['color'] as string[],
|
|
cols: ['shape'] as string[],
|
|
aggregatorName: 'Count',
|
|
vals: [] as string[],
|
|
aggregatorsFactory,
|
|
tableOptions: {},
|
|
onContextMenu: jest.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('TableRenderer renders a table element with the pvtTable class', () => {
|
|
const props = buildDefaultProps();
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const table = screen.getByRole('grid');
|
|
expect(table).toBeInTheDocument();
|
|
expect(table).toHaveClass('pvtTable');
|
|
});
|
|
|
|
test('TableRenderer renders column headers from pivot data', () => {
|
|
const props = buildDefaultProps();
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// The column attribute values ("circle" and "square") should appear as
|
|
// column headers in the rendered table.
|
|
expect(screen.getByText('circle')).toBeInTheDocument();
|
|
expect(screen.getByText('square')).toBeInTheDocument();
|
|
});
|
|
|
|
test('TableRenderer renders row headers from pivot data', () => {
|
|
const props = buildDefaultProps();
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// The row attribute values ("blue" and "red") should appear as
|
|
// row headers in the rendered table.
|
|
expect(screen.getByText('blue')).toBeInTheDocument();
|
|
expect(screen.getByText('red')).toBeInTheDocument();
|
|
});
|
|
|
|
test('TableRenderer renders aggregated cell values', () => {
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_COUNT_DATA,
|
|
vals: ['value'],
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// Each leaf cell (row x col intersection) holds the DB-computed value "1".
|
|
const cells = screen.getAllByRole('gridcell');
|
|
const cellTexts = cells.map(cell => cell.textContent);
|
|
|
|
// There should be a "1" leaf cell for each of the four intersections
|
|
// (blue+circle, blue+square, red+circle, red+square). The default formatter
|
|
// renders with two decimals in this test (production applies the metric's
|
|
// own format).
|
|
const onesCount = cellTexts.filter(text => text === '1.00').length;
|
|
expect(onesCount).toBeGreaterThanOrEqual(4);
|
|
});
|
|
|
|
test('TableRenderer renders row totals when rowTotals is enabled', () => {
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_COUNT_DATA,
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// Row totals column should show the DB-computed "2" for each color (blue has
|
|
// 2 records, red has 2 records).
|
|
const totalCells = screen
|
|
.getAllByRole('gridcell')
|
|
.filter(cell => cell.classList.contains('pvtTotal'));
|
|
expect(totalCells.length).toBeGreaterThan(0);
|
|
|
|
const totalValues = totalCells.map(cell => cell.textContent);
|
|
expect(totalValues).toContain('2.00');
|
|
});
|
|
|
|
test('TableRenderer renders col totals row when colTotals is enabled', () => {
|
|
const props = buildDefaultProps({
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// The totals row should have cells with class pvtRowTotal.
|
|
const rowTotalCells = screen
|
|
.getAllByRole('gridcell')
|
|
.filter(cell => cell.classList.contains('pvtRowTotal'));
|
|
expect(rowTotalCells.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('TableRenderer renders grand total when both totals are enabled', () => {
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_COUNT_DATA,
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// The grand total cell shows the DB-computed "4" (total record count).
|
|
const grandTotalCells = screen
|
|
.getAllByRole('gridcell')
|
|
.filter(cell => cell.classList.contains('pvtGrandTotal'));
|
|
expect(grandTotalCells.length).toBe(1);
|
|
expect(grandTotalCells[0]).toHaveTextContent('4');
|
|
});
|
|
|
|
/**
|
|
* Metric-collapse totals: when the metric pseudo-dimension is the only thing on
|
|
* an axis (here columns), the opposite "Total" axis and the grand-total corner
|
|
* must still show values rather than null, because no rollup level produces an
|
|
* empty key on the metric axis. Records carry `__metricKey` so PivotData can
|
|
* mirror the value into rowTotals / allTotal. (Regression guard for the gap that
|
|
* in-app verification surfaced: a null right-hand "Total" column.)
|
|
*/
|
|
const TAGGED_METRIC_ON_COLUMNS = [
|
|
// leaf cells: rows = [color], columns = [Metric] (metric on the column axis)
|
|
{
|
|
color: 'blue',
|
|
Metric: 'm1',
|
|
value: 10,
|
|
__rows: ['color'],
|
|
__columns: ['Metric'],
|
|
__metricKey: 'Metric',
|
|
},
|
|
{
|
|
color: 'red',
|
|
Metric: 'm1',
|
|
value: 20,
|
|
__rows: ['color'],
|
|
__columns: ['Metric'],
|
|
__metricKey: 'Metric',
|
|
},
|
|
// grand total level: rows = [], columns = [Metric]
|
|
{
|
|
Metric: 'm1',
|
|
value: 30,
|
|
__rows: [],
|
|
__columns: ['Metric'],
|
|
__metricKey: 'Metric',
|
|
},
|
|
];
|
|
|
|
test('TableRenderer fills metric-collapse totals (no null Total column/corner)', () => {
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_METRIC_ON_COLUMNS,
|
|
rows: ['color'],
|
|
cols: ['Metric'],
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// Right-hand "Total" column (rowTotals) shows the per-row collapsed values...
|
|
const rowTotalTexts = screen
|
|
.getAllByRole('gridcell')
|
|
.filter(cell => cell.classList.contains('pvtTotal'))
|
|
.map(cell => cell.textContent);
|
|
expect(rowTotalTexts).toContain('10.00');
|
|
expect(rowTotalTexts).toContain('20.00');
|
|
expect(rowTotalTexts).not.toContain('null');
|
|
|
|
// ...and the grand-total corner shows the collapsed grand total (not null).
|
|
const grandTotalCells = screen
|
|
.getAllByRole('gridcell')
|
|
.filter(cell => cell.classList.contains('pvtGrandTotal'));
|
|
expect(grandTotalCells.length).toBe(1);
|
|
expect(grandTotalCells[0]).toHaveTextContent('30.00');
|
|
});
|
|
|
|
test('TableRenderer handles empty data gracefully', () => {
|
|
const props = buildDefaultProps({ data: [] });
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// The table should still render without crashing, just with no data rows.
|
|
const table = screen.getByRole('grid');
|
|
expect(table).toBeInTheDocument();
|
|
|
|
// With empty data, there are no regular value cells (pvtVal).
|
|
const valueCells = document.querySelectorAll('.pvtVal');
|
|
expect(valueCells).toHaveLength(0);
|
|
|
|
// No row headers should be present.
|
|
const rowLabels = document.querySelectorAll('.pvtRowLabel');
|
|
expect(rowLabels).toHaveLength(0);
|
|
});
|
|
|
|
test('TableRenderer handles data with no rows dimension', () => {
|
|
const props = buildDefaultProps({
|
|
rows: [],
|
|
cols: ['color'],
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const table = screen.getByRole('grid');
|
|
expect(table).toBeInTheDocument();
|
|
|
|
// Column headers should still render.
|
|
expect(screen.getByText('blue')).toBeInTheDocument();
|
|
expect(screen.getByText('red')).toBeInTheDocument();
|
|
});
|
|
|
|
test('TableRenderer handles data with no cols dimension', () => {
|
|
const props = buildDefaultProps({
|
|
rows: ['color'],
|
|
cols: [],
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const table = screen.getByRole('grid');
|
|
expect(table).toBeInTheDocument();
|
|
|
|
// Row headers should still render.
|
|
expect(screen.getByText('blue')).toBeInTheDocument();
|
|
expect(screen.getByText('red')).toBeInTheDocument();
|
|
});
|
|
|
|
test('TableRenderer renders with Sum aggregator', () => {
|
|
const props = buildDefaultProps({
|
|
aggregatorName: 'Sum',
|
|
vals: ['value'],
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const cells = screen.getAllByRole('gridcell');
|
|
const cellTexts = cells.map(cell => cell.textContent);
|
|
|
|
// Sum of value for blue+circle=10, blue+square=20, red+circle=30,
|
|
// red+square=40. Check that at least some of these appear.
|
|
expect(cellTexts.some(text => text?.includes('10'))).toBe(true);
|
|
expect(cellTexts.some(text => text?.includes('40'))).toBe(true);
|
|
});
|
|
|
|
test('TableRenderer applies namesMapping to header labels', () => {
|
|
const props = buildDefaultProps({
|
|
namesMapping: { blue: 'Blue Color', red: 'Red Color' },
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
expect(screen.getByText('Blue Color')).toBeInTheDocument();
|
|
expect(screen.getByText('Red Color')).toBeInTheDocument();
|
|
});
|
|
|
|
test('TableRenderer renders the row attribute label in the header', () => {
|
|
const props = buildDefaultProps();
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// The row attribute name "color" should appear as an axis label.
|
|
const axisLabels = document.querySelectorAll('.pvtAxisLabel');
|
|
const axisLabelTexts = Array.from(axisLabels).map(el => el.textContent);
|
|
expect(axisLabelTexts).toContain('color');
|
|
});
|
|
|
|
test('TableRenderer renders the column attribute label in the header', () => {
|
|
const props = buildDefaultProps();
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// The column attribute name "shape" should appear as an axis label.
|
|
const axisLabels = document.querySelectorAll('.pvtAxisLabel');
|
|
const axisLabelTexts = Array.from(axisLabels).map(el => el.textContent);
|
|
expect(axisLabelTexts).toContain('shape');
|
|
});
|
|
|
|
test('TableRenderer calls onContextMenu callback', () => {
|
|
const onContextMenu = jest.fn();
|
|
const props = buildDefaultProps({
|
|
onContextMenu,
|
|
tableOptions: { highlightHeaderCellsOnHover: true },
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// The column attribute value "circle" is rendered inside a header <th> whose
|
|
// onContextMenu handler calls the callback.
|
|
const columnHeaderCell = screen.getByText('circle').closest('th');
|
|
expect(columnHeaderCell).not.toBeNull();
|
|
fireEvent.contextMenu(columnHeaderCell!);
|
|
|
|
expect(onContextMenu).toHaveBeenCalledTimes(1);
|
|
const [, colKey, rowKey, filters] = onContextMenu.mock.calls[0];
|
|
expect(colKey).toEqual(['circle']);
|
|
expect(rowKey).toBeUndefined();
|
|
expect(filters).toEqual({ shape: 'circle' });
|
|
});
|
|
|
|
test('TableRenderer renders with multiple row dimensions', () => {
|
|
const multiRowData = [
|
|
{ country: 'US', city: 'NYC', value: 10 },
|
|
{ country: 'US', city: 'LA', value: 20 },
|
|
{ country: 'UK', city: 'London', value: 30 },
|
|
];
|
|
|
|
const props = buildDefaultProps({
|
|
data: multiRowData,
|
|
rows: ['country', 'city'],
|
|
cols: [],
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const table = screen.getByRole('grid');
|
|
expect(table).toBeInTheDocument();
|
|
|
|
expect(screen.getByText('US')).toBeInTheDocument();
|
|
expect(screen.getByText('UK')).toBeInTheDocument();
|
|
expect(screen.getByText('NYC')).toBeInTheDocument();
|
|
expect(screen.getByText('LA')).toBeInTheDocument();
|
|
expect(screen.getByText('London')).toBeInTheDocument();
|
|
});
|
|
|
|
test('TableRenderer renders with multiple column dimensions', () => {
|
|
const multiColData = [
|
|
{ year: '2023', quarter: 'Q1', metric: 5 },
|
|
{ year: '2023', quarter: 'Q2', metric: 10 },
|
|
{ year: '2024', quarter: 'Q1', metric: 15 },
|
|
];
|
|
|
|
const props = buildDefaultProps({
|
|
data: multiColData,
|
|
rows: [],
|
|
cols: ['year', 'quarter'],
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const table = screen.getByRole('grid');
|
|
expect(table).toBeInTheDocument();
|
|
|
|
expect(screen.getByText('2023')).toBeInTheDocument();
|
|
expect(screen.getByText('2024')).toBeInTheDocument();
|
|
// Q1 appears under both 2023 and 2024, so use getAllByText.
|
|
expect(screen.getAllByText('Q1').length).toBeGreaterThanOrEqual(2);
|
|
expect(screen.getByText('Q2')).toBeInTheDocument();
|
|
});
|
|
|
|
test('TableRenderer renders value cells with the pvtVal class', () => {
|
|
const props = buildDefaultProps();
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const valueCells = document.querySelectorAll('.pvtVal');
|
|
// 2 rows x 2 cols = 4 value cells
|
|
expect(valueCells.length).toBe(4);
|
|
});
|
|
|
|
test('TableRenderer coerces numeric timestamp strings to numbers for column header date formatters', () => {
|
|
const dateFormatter = jest.fn((val: unknown) => `col:${String(val)}`);
|
|
const data = [
|
|
{ shape: '1700000000000', color: 'blue', value: 1 },
|
|
{ shape: 'square', color: 'blue', value: 2 },
|
|
];
|
|
const props = buildDefaultProps({
|
|
data,
|
|
rows: ['color'],
|
|
cols: ['shape'],
|
|
tableOptions: { dateFormatters: { shape: dateFormatter } },
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// Numeric string should be coerced to a Number before being passed to the
|
|
// date formatter; plain (non-numeric) strings should pass through verbatim.
|
|
expect(dateFormatter).toHaveBeenCalledWith(1700000000000);
|
|
expect(dateFormatter).toHaveBeenCalledWith('square');
|
|
|
|
expect(screen.getByText('col:1700000000000')).toBeInTheDocument();
|
|
expect(screen.getByText('col:square')).toBeInTheDocument();
|
|
});
|
|
|
|
type TestData = {
|
|
[key: string]: number | string | null;
|
|
};
|
|
|
|
const createMockAggregator =
|
|
(data: TestData) =>
|
|
(key: string[], _context: never[]): unknown => {
|
|
const keyStr = key.join('|');
|
|
return data[keyStr] ?? null;
|
|
};
|
|
|
|
test('should sort flat keys in ascending order', () => {
|
|
const keys: string[][] = [['A'], ['C'], ['B']];
|
|
const data = {
|
|
A: 30,
|
|
B: 10,
|
|
C: 20,
|
|
};
|
|
|
|
groupingValueSort(keys, createMockAggregator(data), false, true);
|
|
|
|
expect(keys).toEqual([['B'], ['C'], ['A']]);
|
|
});
|
|
|
|
test('should sort flat keys in descending order', () => {
|
|
const keys: string[][] = [['A'], ['C'], ['B']];
|
|
const data = {
|
|
A: 30,
|
|
B: 10,
|
|
C: 20,
|
|
};
|
|
|
|
groupingValueSort(keys, createMockAggregator(data), false, false);
|
|
|
|
expect(keys).toEqual([['A'], ['C'], ['B']]);
|
|
});
|
|
|
|
test('should place subtotal at top when top=true and ascending', () => {
|
|
const keys: string[][] = [
|
|
['Region', 'City1'],
|
|
['Region'],
|
|
['Region', 'City2'],
|
|
];
|
|
const data = {
|
|
Region: 150,
|
|
'Region|City1': 100,
|
|
'Region|City2': 50,
|
|
};
|
|
|
|
groupingValueSort(keys, createMockAggregator(data), true, true);
|
|
|
|
expect(keys[0]).toEqual(['Region']);
|
|
expect(keys[1]).toEqual(['Region', 'City2']);
|
|
expect(keys[2]).toEqual(['Region', 'City1']);
|
|
});
|
|
|
|
test('should place subtotal at bottom when top=false and descending', () => {
|
|
const keys: string[][] = [
|
|
['Region', 'City1'],
|
|
['Region'],
|
|
['Region', 'City2'],
|
|
];
|
|
const data = {
|
|
'Region|City1': 100,
|
|
'Region|City2': 50,
|
|
Region: 150,
|
|
};
|
|
|
|
groupingValueSort(keys, createMockAggregator(data), false, false);
|
|
|
|
expect(keys[0]).toEqual(['Region', 'City1']);
|
|
expect(keys[1]).toEqual(['Region', 'City2']);
|
|
expect(keys[2]).toEqual(['Region']);
|
|
});
|
|
|
|
test('should use alphabetical order for terminals with equal values', () => {
|
|
const keys: string[][] = [
|
|
['Group', 'Apple'],
|
|
['Group', 'Banana'],
|
|
['Group', 'Cherry'],
|
|
];
|
|
const data = {
|
|
'Group|Apple': 50,
|
|
'Group|Banana': 50,
|
|
'Group|Cherry': 50,
|
|
};
|
|
|
|
groupingValueSort(keys, createMockAggregator(data), false, true);
|
|
|
|
expect(keys).toEqual([
|
|
['Group', 'Apple'],
|
|
['Group', 'Banana'],
|
|
['Group', 'Cherry'],
|
|
]);
|
|
});
|
|
|
|
test('should handle null values gracefully', () => {
|
|
const keys: string[][] = [['A'], ['B'], ['C']];
|
|
const data = {
|
|
A: 100,
|
|
B: null,
|
|
C: 50,
|
|
};
|
|
|
|
groupingValueSort(keys, createMockAggregator(data), false, true);
|
|
expect(keys).toEqual([['B'], ['C'], ['A']]);
|
|
});
|
|
|
|
test('should handle string numbers', () => {
|
|
const keys: string[][] = [['A'], ['B'], ['C']];
|
|
const data = {
|
|
A: '100',
|
|
B: '50',
|
|
C: '200',
|
|
};
|
|
|
|
groupingValueSort(keys, createMockAggregator(data), false, false);
|
|
expect(keys).toEqual([['C'], ['A'], ['B']]);
|
|
});
|
|
|
|
test('should handle NaN values', () => {
|
|
const keys: string[][] = [['A'], ['B'], ['C']];
|
|
const data = {
|
|
A: 100,
|
|
B: NaN,
|
|
C: 50,
|
|
};
|
|
|
|
groupingValueSort(keys, createMockAggregator(data), false, true);
|
|
expect(keys).toEqual([['B'], ['C'], ['A']]);
|
|
});
|
|
|
|
test('should handle single key', () => {
|
|
const keys: string[][] = [['OnlyKey']];
|
|
const data = { OnlyKey: 42 };
|
|
|
|
groupingValueSort(keys, createMockAggregator(data), false, true);
|
|
expect(keys).toEqual([['OnlyKey']]);
|
|
});
|
|
|
|
test('should handle empty keys array', () => {
|
|
const keys: string[][] = [];
|
|
const data = {};
|
|
|
|
groupingValueSort(keys, createMockAggregator(data), false, true);
|
|
expect(keys).toEqual([]);
|
|
});
|
|
|
|
test('should handle product categories with subcategories', () => {
|
|
const keys: string[][] = [
|
|
['Electronics'],
|
|
['Electronics', 'Phones'],
|
|
['Electronics', 'Phones', 'iPhone'],
|
|
['Electronics', 'Phones', 'Samsung'],
|
|
['Electronics', 'Laptops'],
|
|
['Electronics', 'Laptops', 'MacBook'],
|
|
['Clothing'],
|
|
['Clothing', 'Shirts'],
|
|
['Clothing', 'Shirts', 'T-Shirt'],
|
|
['Clothing', 'Pants'],
|
|
['Clothing', 'Pants', 'Jeans'],
|
|
];
|
|
const data = {
|
|
Electronics: 2100,
|
|
'Electronics|Phones': 900,
|
|
'Electronics|Phones|iPhone': 500,
|
|
'Electronics|Phones|Samsung': 400,
|
|
'Electronics|Laptops': 1200,
|
|
'Electronics|Laptops|MacBook': 1200,
|
|
Clothing: 2550,
|
|
'Clothing|Shirts': 1400,
|
|
'Clothing|Shirts|T-Shirt': 1400,
|
|
'Clothing|Pants': 1150,
|
|
'Clothing|Pants|Jeans': 1150,
|
|
};
|
|
|
|
groupingValueSort(keys, createMockAggregator(data), true, true);
|
|
|
|
expect(keys[0]).toEqual(['Electronics']);
|
|
expect(keys[1]).toEqual(['Electronics', 'Phones']);
|
|
expect(keys[2]).toEqual(['Electronics', 'Phones', 'Samsung']);
|
|
expect(keys[3]).toEqual(['Electronics', 'Phones', 'iPhone']);
|
|
expect(keys[4]).toEqual(['Electronics', 'Laptops']);
|
|
expect(keys[5]).toEqual(['Electronics', 'Laptops', 'MacBook']);
|
|
expect(keys[6]).toEqual(['Clothing']);
|
|
expect(keys[7]).toEqual(['Clothing', 'Pants']);
|
|
expect(keys[8]).toEqual(['Clothing', 'Pants', 'Jeans']);
|
|
expect(keys[9]).toEqual(['Clothing', 'Shirts']);
|
|
expect(keys[10]).toEqual(['Clothing', 'Shirts', 'T-Shirt']);
|
|
});
|
|
|
|
test('TableRenderer coerces numeric timestamp strings to numbers for row header date formatters', () => {
|
|
const dateFormatter = jest.fn((val: unknown) => `row:${String(val)}`);
|
|
const data = [
|
|
{ color: '1700000000000', shape: 'circle', value: 1 },
|
|
{ color: 'red', shape: 'circle', value: 2 },
|
|
];
|
|
const props = buildDefaultProps({
|
|
data,
|
|
rows: ['color'],
|
|
cols: ['shape'],
|
|
tableOptions: { dateFormatters: { color: dateFormatter } },
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// Row-header path mirrors the column path: numeric strings coerce to
|
|
// Number, non-numeric strings pass through verbatim.
|
|
expect(dateFormatter).toHaveBeenCalledWith(1700000000000);
|
|
expect(dateFormatter).toHaveBeenCalledWith('red');
|
|
|
|
expect(screen.getByText('row:1700000000000')).toBeInTheDocument();
|
|
expect(screen.getByText('row:red')).toBeInTheDocument();
|
|
});
|
|
|
|
test('TableRenderer applies cellColorFormatters background and contrast color to column headers', () => {
|
|
const cellColorFormatters = {
|
|
shape: [
|
|
{
|
|
column: 'shape',
|
|
getColorFromValue: (val: unknown) =>
|
|
val === 'circle' ? '#ff0000' : undefined,
|
|
},
|
|
],
|
|
};
|
|
const props = buildDefaultProps({
|
|
tableOptions: { cellColorFormatters },
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// The matching column header should pick up the formatter's background
|
|
// color and a contrast-aware text color from getTextColorForBackground.
|
|
const formattedHeader = screen.getByText('circle').closest('th');
|
|
expect(formattedHeader).not.toBeNull();
|
|
expect(formattedHeader!.style.backgroundColor).not.toBe('');
|
|
expect(formattedHeader!.style.color).not.toBe('');
|
|
|
|
// The non-matching header should not get a background applied.
|
|
const plainHeader = screen.getByText('square').closest('th');
|
|
expect(plainHeader!.style.backgroundColor).toBe('');
|
|
});
|
|
|
|
test('TableRenderer applies cellColorFormatters background and contrast color to value cells', () => {
|
|
// Value-cell formatters are matched against actual row/col key values
|
|
// (not attribute names), so a formatter with column: 'blue' fires for
|
|
// every value cell whose row key contains 'blue'.
|
|
const cellColorFormatters = {
|
|
color: [
|
|
{
|
|
column: 'blue',
|
|
getColorFromValue: () => '#000000',
|
|
},
|
|
],
|
|
};
|
|
const props = buildDefaultProps({
|
|
tableOptions: { cellColorFormatters },
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const valueCells = Array.from(
|
|
document.querySelectorAll<HTMLElement>('.pvtVal'),
|
|
);
|
|
expect(valueCells.length).toBeGreaterThan(0);
|
|
|
|
// At least one value cell in the "blue" row should have both a background
|
|
// and a contrast-aware text color applied.
|
|
const formattedCells = valueCells.filter(
|
|
cell => cell.style.backgroundColor !== '',
|
|
);
|
|
expect(formattedCells.length).toBeGreaterThan(0);
|
|
formattedCells.forEach(cell => {
|
|
expect(cell.style.color).not.toBe('');
|
|
});
|
|
});
|
|
|
|
test('TableRenderer renders correct number of thead and tbody sections', () => {
|
|
const props = buildDefaultProps();
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const table = screen.getByRole('grid');
|
|
|
|
// The table should have thead and tbody elements.
|
|
const theadEl = table.querySelector('thead');
|
|
const tbodyEl = table.querySelector('tbody');
|
|
expect(theadEl).toBeInTheDocument();
|
|
expect(tbodyEl).toBeInTheDocument();
|
|
});
|
|
|
|
/**
|
|
* "Show values as" a fraction (percent_row/percent_col/percent_total): a pure
|
|
* display transform over the already DB-correct rollup values in
|
|
* TAGGED_COUNT_DATA (leaf cells = 1, row/col totals = 2, grand total = 4).
|
|
* Reintroduces the pre-SIP-216 "Sum as Fraction of ..." display, but as a
|
|
* standalone control rather than resurrecting the removed per-metric
|
|
* "Aggregation function" selector -- see PivotData's constructor in
|
|
* ../../src/react-pivottable/utilities.ts.
|
|
*/
|
|
function getCellTexts(className: string) {
|
|
return screen
|
|
.getAllByRole('gridcell')
|
|
.filter(cell => cell.classList.contains(className))
|
|
.map(cell => cell.textContent);
|
|
}
|
|
|
|
test('TableRenderer shows values as a percentage of the grand total', () => {
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_COUNT_DATA,
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
showValuesAs: 'percent_total',
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// Each leaf cell is 1 out of a grand total of 4.
|
|
expect(getCellTexts('pvtVal')).toEqual(
|
|
expect.arrayContaining(['25.0%', '25.0%', '25.0%', '25.0%']),
|
|
);
|
|
// Row and column totals are 2 out of 4.
|
|
const rowTotalCells = getCellTexts('pvtTotal').filter(
|
|
text => text === '50.0%',
|
|
);
|
|
expect(rowTotalCells.length).toBeGreaterThan(0);
|
|
// The grand total is always 100% of itself.
|
|
const grandTotalCells = screen
|
|
.getAllByRole('gridcell')
|
|
.filter(cell => cell.classList.contains('pvtGrandTotal'));
|
|
expect(grandTotalCells).toHaveLength(1);
|
|
expect(grandTotalCells[0]).toHaveTextContent('100.0%');
|
|
});
|
|
|
|
test('TableRenderer shows values as a percentage of the row total', () => {
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_COUNT_DATA,
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
showValuesAs: 'percent_row',
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// Each leaf cell (1) is half of its row's total (2).
|
|
expect(getCellTexts('pvtVal')).toEqual(
|
|
expect.arrayContaining(['50.0%', '50.0%', '50.0%', '50.0%']),
|
|
);
|
|
// A row total is 100% of itself.
|
|
expect(getCellTexts('pvtTotal')).toEqual(expect.arrayContaining(['100.0%']));
|
|
});
|
|
|
|
test('TableRenderer shows values as a percentage of the column total', () => {
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_COUNT_DATA,
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
showValuesAs: 'percent_col',
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// Each leaf cell (1) is half of its column's total (2).
|
|
expect(getCellTexts('pvtVal')).toEqual(
|
|
expect.arrayContaining(['50.0%', '50.0%', '50.0%', '50.0%']),
|
|
);
|
|
// A column total is 100% of itself.
|
|
expect(getCellTexts('pvtTotal')).toEqual(expect.arrayContaining(['100.0%']));
|
|
});
|
|
|
|
/**
|
|
* Regression guard: when the metric pseudo-dimension collapses to the only
|
|
* thing on an axis (the grand-total level), each metric's own grand total
|
|
* must be used as the `showValuesAs` denominator -- not whichever metric's
|
|
* record was pushed last into the shared "Metric-collapse totals" slot (see
|
|
* `processRecord` in ../../src/react-pivottable/utilities.ts).
|
|
*/
|
|
const TAGGED_MULTI_METRIC_ON_COLUMNS = [
|
|
// leaf cells for metric m1 (grand total 30)
|
|
{
|
|
color: 'blue',
|
|
Metric: 'm1',
|
|
value: 10,
|
|
__rows: ['color'],
|
|
__columns: ['Metric'],
|
|
__metricKey: 'Metric',
|
|
},
|
|
{
|
|
color: 'red',
|
|
Metric: 'm1',
|
|
value: 20,
|
|
__rows: ['color'],
|
|
__columns: ['Metric'],
|
|
__metricKey: 'Metric',
|
|
},
|
|
// leaf cells for metric m2 (grand total 300) -- a different ratio so a
|
|
// cross-metric mixup produces a distinctly wrong percentage.
|
|
{
|
|
color: 'blue',
|
|
Metric: 'm2',
|
|
value: 250,
|
|
__rows: ['color'],
|
|
__columns: ['Metric'],
|
|
__metricKey: 'Metric',
|
|
},
|
|
{
|
|
color: 'red',
|
|
Metric: 'm2',
|
|
value: 50,
|
|
__rows: ['color'],
|
|
__columns: ['Metric'],
|
|
__metricKey: 'Metric',
|
|
},
|
|
// grand total level: rows = [], columns = [Metric]. m2 is pushed last.
|
|
{
|
|
Metric: 'm1',
|
|
value: 30,
|
|
__rows: [],
|
|
__columns: ['Metric'],
|
|
__metricKey: 'Metric',
|
|
},
|
|
{
|
|
Metric: 'm2',
|
|
value: 300,
|
|
__rows: [],
|
|
__columns: ['Metric'],
|
|
__metricKey: 'Metric',
|
|
},
|
|
];
|
|
|
|
test("TableRenderer divides percent_total by each metric's own grand total", () => {
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_MULTI_METRIC_ON_COLUMNS,
|
|
rows: ['color'],
|
|
cols: ['Metric'],
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
showValuesAs: 'percent_total',
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const cellTexts = getCellTexts('pvtVal');
|
|
// m1: 10/30 and 20/30 -- correct only if m1's own grand total (30) is used.
|
|
expect(cellTexts).toEqual(expect.arrayContaining(['33.3%', '66.7%']));
|
|
// m2: 250/300 and 50/300.
|
|
expect(cellTexts).toEqual(expect.arrayContaining(['83.3%', '16.7%']));
|
|
// A "last metric wins" bug would divide m1's cells by m2's grand total
|
|
// (300) instead, producing 3.3%/6.7%.
|
|
expect(cellTexts).not.toEqual(expect.arrayContaining(['3.3%']));
|
|
expect(cellTexts).not.toEqual(expect.arrayContaining(['6.7%']));
|
|
});
|
|
|
|
test('TableRenderer shows actual values when showValuesAs is unset (default)', () => {
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_COUNT_DATA,
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
// No percent signs anywhere -- the DB-computed values render as-is.
|
|
const allCellTexts = screen
|
|
.getAllByRole('gridcell')
|
|
.map(cell => cell.textContent);
|
|
expect(allCellTexts.some(text => text?.includes('%'))).toBe(false);
|
|
expect(getCellTexts('pvtVal')).toEqual(
|
|
expect.arrayContaining(['1.00', '1.00', '1.00', '1.00']),
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Regression guard: the grand-total corner cell is a single shared aggregator
|
|
* slot that "Metric-collapse totals" mirrors every metric's grand-total
|
|
* record into (see `processRecord`), so both its own value and `metricAxis`
|
|
* reflect the last metric pushed (m2). The denominator lookup must resolve
|
|
* against that same metric's own total (m2's 300, not m1's 30) so the corner
|
|
* cell reads a self-consistent 100% instead of an obviously wrong
|
|
* cross-metric ratio (300 / 30 = "1000.0%").
|
|
*/
|
|
test('TableRenderer keeps the grand-total corner cell self-consistent when it mixes multiple metrics in fraction mode', () => {
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_MULTI_METRIC_ON_COLUMNS,
|
|
rows: ['color'],
|
|
cols: ['Metric'],
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
showValuesAs: 'percent_total',
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const grandTotalCells = screen
|
|
.getAllByRole('gridcell')
|
|
.filter(cell => cell.classList.contains('pvtGrandTotal'));
|
|
expect(grandTotalCells).toHaveLength(1);
|
|
expect(grandTotalCells[0]).toHaveTextContent('100.0%');
|
|
});
|
|
|
|
/**
|
|
* Regression guard: a DB-computed value can be a genuine SQL NULL (e.g. AVG
|
|
* over an empty group). `null / acc` coerces to `0` in JS, which would
|
|
* previously render a measured "0.0%" for a cell that renders blank in
|
|
* "Actual values" mode. Fraction mode must preserve that blank instead of
|
|
* turning an undefined value into a measured zero.
|
|
*/
|
|
const TAGGED_DATA_WITH_NULL_LEAF = [
|
|
{
|
|
color: 'blue',
|
|
shape: 'circle',
|
|
value: null,
|
|
__rows: ['color'],
|
|
__columns: ['shape'],
|
|
},
|
|
{
|
|
color: 'blue',
|
|
shape: 'square',
|
|
value: 20,
|
|
__rows: ['color'],
|
|
__columns: ['shape'],
|
|
},
|
|
{
|
|
color: 'red',
|
|
shape: 'circle',
|
|
value: 30,
|
|
__rows: ['color'],
|
|
__columns: ['shape'],
|
|
},
|
|
{
|
|
color: 'red',
|
|
shape: 'square',
|
|
value: 40,
|
|
__rows: ['color'],
|
|
__columns: ['shape'],
|
|
},
|
|
{ color: 'blue', value: 20, __rows: ['color'], __columns: [] },
|
|
{ color: 'red', value: 70, __rows: ['color'], __columns: [] },
|
|
{ shape: 'circle', value: 30, __rows: [], __columns: ['shape'] },
|
|
{ shape: 'square', value: 60, __rows: [], __columns: ['shape'] },
|
|
{ value: 90, __rows: [], __columns: [] },
|
|
];
|
|
|
|
test('TableRenderer keeps a null metric value blank in fraction mode instead of showing 0.0%', () => {
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_DATA_WITH_NULL_LEAF,
|
|
rows: ['color'],
|
|
cols: ['shape'],
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
showValuesAs: 'percent_row',
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const cellTexts = getCellTexts('pvtVal');
|
|
expect(cellTexts).not.toContain('0.0%');
|
|
expect(cellTexts).toContain('');
|
|
// The non-null sibling cell in the same row still divides correctly
|
|
// (20 / 20 row total).
|
|
expect(cellTexts).toContain('100.0%');
|
|
});
|
|
|
|
/**
|
|
* Regression guard: the denominator (not just the numerator) can itself be a
|
|
* genuine SQL NULL -- e.g. a row total that's an AVG over an empty group.
|
|
* `numerator / null` coerces to `numerator / 0` in JS, producing `Infinity`
|
|
* (for a nonzero numerator) instead of the blank cell that a missing/null
|
|
* total should render as everywhere else.
|
|
*/
|
|
const TAGGED_DATA_WITH_NULL_ROW_TOTAL = [
|
|
{
|
|
color: 'blue',
|
|
shape: 'circle',
|
|
value: 10,
|
|
__rows: ['color'],
|
|
__columns: ['shape'],
|
|
},
|
|
{
|
|
color: 'blue',
|
|
shape: 'square',
|
|
value: 20,
|
|
__rows: ['color'],
|
|
__columns: ['shape'],
|
|
},
|
|
// blue's row total is itself null (e.g. AVG over an empty group).
|
|
{ color: 'blue', value: null, __rows: ['color'], __columns: [] },
|
|
];
|
|
|
|
test('TableRenderer keeps cells blank in fraction mode when the denominator total is null', () => {
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_DATA_WITH_NULL_ROW_TOTAL,
|
|
rows: ['color'],
|
|
cols: ['shape'],
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
showValuesAs: 'percent_row',
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const cellTexts = getCellTexts('pvtVal');
|
|
expect(cellTexts).not.toEqual(
|
|
expect.arrayContaining([expect.stringContaining('Infinity')]),
|
|
);
|
|
expect(cellTexts).not.toEqual(
|
|
expect.arrayContaining([expect.stringContaining('NaN')]),
|
|
);
|
|
expect(cellTexts).toEqual(['', '']);
|
|
});
|
|
|
|
/**
|
|
* The rendered text alone can't distinguish `null` from `Infinity`/`NaN` --
|
|
* the shared number formatter already blanks any non-finite value, so the
|
|
* DOM-level test above would pass even without the `acc === null` guard.
|
|
* Assert the aggregator's own value() contract directly: it must return
|
|
* `null` (not a non-finite number) when the denominator total is null, since
|
|
* other code paths that read `.value()` outside of `.format()` -- e.g.
|
|
* value-based row/column sorting -- rely on that contract to treat it as "no
|
|
* value" the same way an actually-missing total does.
|
|
*/
|
|
test("fractionOf's value() returns null, not Infinity, when the denominator total is null", () => {
|
|
const pivotData = new PivotData({
|
|
data: TAGGED_DATA_WITH_NULL_ROW_TOTAL,
|
|
rows: ['color'],
|
|
cols: ['shape'],
|
|
vals: ['value'],
|
|
showValuesAs: 'percent_row',
|
|
});
|
|
|
|
expect(pivotData.getAggregator(['blue'], ['circle']).value()).toBeNull();
|
|
expect(pivotData.getAggregator(['blue'], ['square']).value()).toBeNull();
|
|
});
|
|
|
|
/**
|
|
* Regression guard: `buildGroupbyCombinations` requests the denominator's
|
|
* rollup level whenever a percent `showValuesAs` is active, but if a cached
|
|
* response predates that (e.g. a stale query result), the level a percent
|
|
* mode needs can be absent from the data entirely. The denominator aggregator
|
|
* then never receives a push and its underlying value stays `null`, which
|
|
* would produce `Infinity` (JS coerces `null` to `0` under `/`) rather than
|
|
* throwing -- the shared number formatter must render that as blank instead
|
|
* of leaking `Infinity%`/`NaN%` into the cell.
|
|
*/
|
|
test('TableRenderer renders blank instead of NaN%/Infinity% when the denominator level is missing', () => {
|
|
// Leaf cells and the grand total are present, but the row-total level
|
|
// (`__rows: ['color'], __columns: []`) that `percent_row` needs is not --
|
|
// simulating a response fetched before the denominator level was requested.
|
|
const dataMissingRowTotals = TAGGED_COUNT_DATA.filter(
|
|
record => !(record.__rows.length === 1 && record.__columns.length === 0),
|
|
);
|
|
const props = buildDefaultProps({
|
|
data: dataMissingRowTotals,
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: false, colTotals: true },
|
|
showValuesAs: 'percent_row',
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const cellTexts = getCellTexts('pvtVal');
|
|
expect(cellTexts.length).toBeGreaterThan(0);
|
|
cellTexts.forEach(text => {
|
|
expect(text).not.toMatch(/NaN|Infinity/);
|
|
});
|
|
expect(cellTexts).toEqual(expect.arrayContaining(['']));
|
|
});
|
|
|
|
/**
|
|
* Regression guard: a per-metric custom formatter (currency, decimal
|
|
* precision, etc.) doesn't apply to a ratio, so `PivotData` disables
|
|
* `formattedAggregators` entirely whenever a fraction `showValuesAs` is
|
|
* active (see the constructor in ../../src/react-pivottable/utilities.ts).
|
|
* A cell whose group would otherwise pick up a custom formatter must still
|
|
* render as a plain percentage.
|
|
*/
|
|
test('TableRenderer ignores customFormatters while showValuesAs is a percentage', () => {
|
|
const customFormatters = {
|
|
color: {
|
|
blue: () => 'CUSTOM',
|
|
},
|
|
};
|
|
const props = buildDefaultProps({
|
|
data: TAGGED_COUNT_DATA,
|
|
vals: ['value'],
|
|
tableOptions: { rowTotals: true, colTotals: true },
|
|
showValuesAs: 'percent_row',
|
|
customFormatters,
|
|
});
|
|
renderWithTheme(<TableRenderer {...props} />);
|
|
|
|
const cellTexts = getCellTexts('pvtVal');
|
|
expect(cellTexts).not.toEqual(expect.arrayContaining(['CUSTOM']));
|
|
// Each leaf cell (1) is still half of its row's total (2).
|
|
expect(cellTexts).toEqual(
|
|
expect.arrayContaining(['50.0%', '50.0%', '50.0%', '50.0%']),
|
|
);
|
|
});
|