Files
superset2/superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx
T

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%']),
);
});