Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions frontend/src/__tests__/chart-a11y.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Issue #744: text alternatives for chart canvases.
*/
import { setChartSummary, renderChartTable, removeChartTable } from '../lib/chart-a11y';

describe('chart-a11y', () => {
let canvas: HTMLCanvasElement;

beforeEach(() => {
document.body.innerHTML = '<section><canvas id="c" role="img" aria-label="static"></canvas><p id="after"></p></section>';
canvas = document.getElementById('c') as HTMLCanvasElement;
});

const data = {
caption: 'Savings',
columns: ['Service', 'Current', 'Total'],
rows: [['compute', '$70', '$210'], ['cache', '$25', '$110']],
};

test('setChartSummary replaces the static aria-label', () => {
setChartSummary(canvas, 'Bar chart of 2 services, $320 in total.');
expect(canvas.getAttribute('aria-label')).toBe('Bar chart of 2 services, $320 in total.');
});

test('renders a collapsed "View as table" disclosure right after the anchor', () => {
renderChartTable(canvas, 't', data);
const details = document.getElementById('t') as HTMLDetailsElement;
expect(details.tagName).toBe('DETAILS');
expect(details.open).toBe(false);
expect(canvas.nextElementSibling).toBe(details);
expect(details.querySelector('summary')?.textContent).toBe('View as table');
expect(details.querySelector('caption')?.textContent).toBe('Savings');
});

test('uses column and row headers so screen readers can announce each cell', () => {
renderChartTable(canvas, 't', data);
const table = document.querySelector('#t table') as HTMLTableElement;
const head = Array.from(table.tHead!.querySelectorAll('th'));
expect(head.map((th) => th.textContent)).toEqual(['Service', 'Current', 'Total']);
expect(head.every((th) => th.scope === 'col')).toBe(true);
const rows = Array.from(table.tBodies[0]!.rows);
expect(rows.map((r) => Array.from(r.cells).map((c) => c.textContent))).toEqual(data.rows);
for (const r of rows) {
expect(r.cells[0]!.tagName).toBe('TH');
expect((r.cells[0] as HTMLTableCellElement).scope).toBe('row');
expect(r.cells[1]!.tagName).toBe('TD');
}
});

test('re-rendering replaces the rows and keeps the open state', () => {
renderChartTable(canvas, 't', data);
(document.getElementById('t') as HTMLDetailsElement).open = true;
renderChartTable(canvas, 't', { ...data, rows: [['rds', '$1', '$2']] });
expect(document.querySelectorAll('#t').length).toBe(1);
expect(document.querySelectorAll('#t table').length).toBe(1);
expect(document.querySelectorAll('#t tbody tr').length).toBe(1);
expect((document.getElementById('t') as HTMLDetailsElement).open).toBe(true);
});

test('cell text is never parsed as HTML', () => {
renderChartTable(canvas, 't', { ...data, rows: [['<img src=x onerror=alert(1)>', '$1', '$2']] });
expect(document.querySelector('#t img')).toBeNull();
expect(document.querySelector('#t tbody th')?.textContent).toBe('<img src=x onerror=alert(1)>');
});

test('removeChartTable drops the disclosure and tolerates a missing one', () => {
renderChartTable(canvas, 't', data);
removeChartTable('t');
expect(document.getElementById('t')).toBeNull();
expect(() => removeChartTable('t')).not.toThrow();
});
});
106 changes: 106 additions & 0 deletions frontend/src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,51 @@ describe('Dashboard Module', () => {
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
});

// #744: summary label + table for the trend chart.
test('describes the plotted series and lists every point as a table (#744)', async () => {
(api.getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [
{ timestamp: '2026-01-01T00:00:00Z', cumulative_savings: 100 },
{ timestamp: '2026-01-02T00:00:00Z', cumulative_savings: 250 },
] });

await loadSavingsTrendChart();

const label = document.getElementById('savings-trend-chart')?.getAttribute('aria-label') ?? '';
expect(label).toContain('cumulative savings over the last');
expect(label).toContain('$250');
expect(label).toContain('2 data points');
const rows = document.querySelectorAll('#savings-trend-table tbody tr');
expect(rows).toHaveLength(2);
expect(rows[1]?.querySelector('td')?.textContent).toBe('$250');
});

// Two points: one point is the sparse-data state (#1334), which draws
// no chart and so has no table.
const twoTrendPoints = [
{ timestamp: '2026-01-01T00:00:00Z', cumulative_savings: 100 },
{ timestamp: '2026-01-02T00:00:00Z', cumulative_savings: 150 },
];

test('removes the trend table when there is no data to plot (#744)', async () => {
(api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: twoTrendPoints });
await loadSavingsTrendChart();
expect(document.getElementById('savings-trend-table')).not.toBeNull();

(api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: [] });
await loadSavingsTrendChart();
expect(document.getElementById('savings-trend-table')).toBeNull();
});

test('removes the trend table in the sparse-data state (#744, #1334)', async () => {
(api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: twoTrendPoints });
await loadSavingsTrendChart();
expect(document.getElementById('savings-trend-table')).not.toBeNull();

(api.getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: [twoTrendPoints[0]] });
await loadSavingsTrendChart();
expect(document.getElementById('savings-trend-table')).toBeNull();
});

test('uses daily interval for the default 90-day range', async () => {
await loadSavingsTrendChart();

Expand Down Expand Up @@ -1792,6 +1837,67 @@ describe('Dashboard Module', () => {

// renderSavingsByService DOM behaviour tests (now driven by recommendation fixtures).
describe('DOM behaviour', () => {
// #744: the role="img" canvas gets a value summary and a table.
describe('text alternative (#744)', () => {
const tableRows = (): string[][] =>
Array.from(document.querySelectorAll('#savings-by-service-table tbody tr'))
.map((tr) => Array.from((tr as HTMLTableRowElement).cells).map((c) => c.textContent ?? ''));

test('summarizes the plotted values in the canvas aria-label', () => {
buildDOM();
renderSavingsByService(
[rec('ec2', 100), rec('ec2', 300, 3), rec('rds', 50)],
{ ec2: { potential_savings: 300, current_savings: 40 } },
);
const label = document.getElementById('savings-by-service-chart')?.getAttribute('aria-label') ?? '';
// ec2: 40 current + 60 lowest option + 200 upside = 300; rds: 50. Total 350.
expect(label).toContain('2 services');
expect(label).toContain('$350 in total');
expect(label).toContain('Largest: ec2 at $300');
});

test('lists every layer value per service in a "View as table" disclosure', () => {
buildDOM();
renderSavingsByService(
[rec('ec2', 100), rec('ec2', 300, 3), rec('rds', 50)],
{ ec2: { potential_savings: 300, current_savings: 40 } },
);
expect(document.querySelector('#savings-by-service-table summary')?.textContent).toBe('View as table');
const headers = Array.from(document.querySelectorAll('#savings-by-service-table thead th')).map((th) => th.textContent);
expect(headers).toEqual(['Service', 'Current / Committed', 'Lowest option', 'Upside', 'Total']);
expect(tableRows()).toEqual([
['ec2', '$40', '$60', '$200', '$300'],
['rds', '$0', '$50', '$0', '$50'],
]);
});

test('the table includes services beyond the bars drawn', () => {
buildDOM();
const many = Array.from({ length: 12 }, (_, i) => rec(`svc-${i}`, 100 + i));
renderSavingsByService(many);
expect(tableRows()).toHaveLength(12);
});

test('the table is removed when the chart switches to its empty state', () => {
buildDOM();
renderSavingsByService([rec('ec2', 100)]);
expect(document.getElementById('savings-by-service-table')).not.toBeNull();
renderSavingsByService([]);
expect(document.getElementById('savings-by-service-table')).toBeNull();
});

test('stacked layers are separated by a 2px gap, not only by shade', () => {
buildDOM();
renderSavingsByService([rec('ec2', 100), rec('ec2', 300, 3)], { ec2: { potential_savings: 300, current_savings: 40 } });
const calls = (Chart as unknown as jest.Mock).mock.calls;
const datasets = (calls[calls.length - 1]?.[1] as { data: { datasets: { borderWidth: { top: number }; borderColor: string }[] } }).data.datasets;
for (const d of datasets) {
expect(d.borderWidth.top).toBe(2);
expect(d.borderColor).toBe('#ffffff');
}
});
});

test('shows empty state and hides canvas when no recommendations', () => {
buildDOM();
renderSavingsByService([]);
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/__tests__/savings-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,42 @@ describe('Savings History Module', () => {
expect(Chart).toHaveBeenCalled();
});

// #744: the canvas gets a value summary and a "View as table" disclosure
// placed after (not inside) the chart's sizing container.
test('adds a summary label and a data table for the plotted points (#744)', async () => {
(getSavingsAnalytics as jest.Mock).mockResolvedValue({
summary: { total_period_savings: 25, total_upfront_spent: 0, purchase_count: 2, average_savings_per_period: 12.5, peak_savings: 15 },
data_points: [
{ timestamp: '2024-01-01T00:00:00Z', total_savings: 10, cumulative_savings: 10, total_upfront: 0, purchase_count: 1 },
{ timestamp: '2024-01-02T00:00:00Z', total_savings: 15, cumulative_savings: 25, total_upfront: 0, purchase_count: 1 },
],
});

await loadSavingsHistory();

const canvas = document.getElementById('savings-history-chart');
expect(canvas?.getAttribute('aria-label')).toContain('2 data points');
expect(canvas?.getAttribute('aria-label')).toContain('$25.00');
const table = document.getElementById('savings-history-table');
expect(document.getElementById('savings-history-container')?.nextElementSibling).toBe(table);
const headers = Array.from(table?.querySelectorAll('thead th') ?? []).map((th) => th.textContent);
expect(headers).toEqual(['Date', 'Period savings (/mo)', 'Cumulative savings']);
const last = Array.from(table?.querySelectorAll('tbody tr') ?? []).pop();
expect(Array.from(last?.querySelectorAll('td') ?? []).map((td) => td.textContent)).toEqual(['$15.00', '$25.00']);
});

test('removes the data table when the chart is replaced by the empty state (#744)', async () => {
(getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({
data_points: [{ timestamp: '2024-01-01T00:00:00Z', total_savings: 10, cumulative_savings: 10, total_upfront: 0, purchase_count: 1 }],
});
await loadSavingsHistory();
expect(document.getElementById('savings-history-table')).not.toBeNull();

(getSavingsAnalytics as jest.Mock).mockResolvedValueOnce({ data_points: [] });
await loadSavingsHistory();
expect(document.getElementById('savings-history-table')).toBeNull();
});

test('shows empty state when no data points', async () => {
(getSavingsAnalytics as jest.Mock).mockResolvedValue({
data_points: []
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { canAccess } from './permissions';
import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale } from './recommendations';
import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton';
import { formatTrendAxisTick, formatTrendTooltipTitle, formatSparseTrendMessage, MIN_TREND_POINTS } from './modules/chart-utils';
import { setChartSummary, renderChartTable, removeChartTable } from './lib/chart-a11y';
// Re-export for backward compatibility (tests and other callers import from dashboard.ts).
export { formatTrendAxisTick } from './modules/chart-utils';

Expand All @@ -32,6 +33,14 @@ let savingsByServiceChart: Chart | null = null;
// Maximum number of services to show in the bar chart before truncating.
const SAVINGS_BY_SERVICE_MAX = 10;

// #744: a 2px card-coloured gap on top of every stacked segment, so adjacent
// layers are separated by an edge as well as by their layer colour.
const SEGMENT_GAP = { borderColor: '#ffffff', borderWidth: { top: 2, right: 0, bottom: 0, left: 0 } } as const;

// Ids of the "View as table" text alternatives for the Home charts (#744).
const SAVINGS_BY_SERVICE_TABLE_ID = 'savings-by-service-table';
const SAVINGS_TREND_TABLE_ID = 'savings-trend-table';

// One colour per stacked layer, the same on every bar, so the legend swatches
// are exactly the colours drawn (#189). The x axis already names each service,
// so hue encodes the layer, not the service: no colour can change with rank
Expand Down Expand Up @@ -895,6 +904,7 @@ export function renderSavingsByService(
if (positive.length === 0) {
if (heading) heading.textContent = 'Potential savings range per service';
canvas.classList.add('hidden');
removeChartTable(SAVINGS_BY_SERVICE_TABLE_ID);
if (emptyEl) {
// When a filter is active and excluded all results, surface it so the
// user understands why the chart is blank (mirrors #747 pattern).
Expand Down Expand Up @@ -933,6 +943,21 @@ export function renderSavingsByService(

const totalSavings = positive.reduce((acc, e) => acc + e.visibleTotal, 0);

// #744: the canvas is role="img", so its label and a table are the only
// way a screen-reader user can read these values. Every service is listed,
// including any beyond the SAVINGS_BY_SERVICE_MAX bars drawn.
const top = positive[0]!;
setChartSummary(canvas,
`Stacked bar chart of monthly savings for ${positive.length} service${positive.length === 1 ? '' : 's'}, `
+ `${formatCurrency(totalSavings)} in total. Largest: ${top.svc} at ${formatCurrency(top.visibleTotal)}. `
+ 'Layers: Current / Committed, Lowest option, Upside. Values are in the table below the chart.');
renderChartTable(canvas, SAVINGS_BY_SERVICE_TABLE_ID, {
caption: 'Potential savings range per service (monthly)',
columns: ['Service', 'Current / Committed', 'Lowest option', 'Upside', 'Total'],
rows: positive.map(e => [e.svc, formatCurrency(e.current), formatCurrency(e.lowestOption),
formatCurrency(e.upside), formatCurrency(e.visibleTotal)]),
});

savingsByServiceChart = new Chart(canvas, {
type: 'bar',
data: {
Expand All @@ -946,20 +971,23 @@ export function renderSavingsByService(
data: currentData,
backgroundColor: SAVINGS_LAYER_COLORS.current,
borderRadius: { topLeft: 0, topRight: 0, bottomLeft: 4, bottomRight: 4 },
...SEGMENT_GAP,
stack: 'savings',
},
{
label: 'Lowest option',
data: lowestOptionData,
backgroundColor: SAVINGS_LAYER_COLORS.lowestOption,
borderRadius: { topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0 },
...SEGMENT_GAP,
stack: 'savings',
},
{
label: 'Upside',
data: upsideData,
backgroundColor: SAVINGS_LAYER_COLORS.upside,
borderRadius: { topLeft: 4, topRight: 4, bottomLeft: 0, bottomRight: 0 },
...SEGMENT_GAP,
stack: 'savings',
},
],
Expand Down Expand Up @@ -1120,6 +1148,7 @@ export async function loadSavingsTrendChart(): Promise<void> {
empty.classList.remove('hidden');
}
if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; }
removeChartTable(SAVINGS_TREND_TABLE_ID);
return;
}
// Sparse data (#188): one point would paint a lone dot on a blank canvas,
Expand All @@ -1131,13 +1160,29 @@ export async function loadSavingsTrendChart(): Promise<void> {
empty.classList.remove('hidden');
}
if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; }
// The sparse message carries the one value; drop any table left from
// an earlier render, as the empty state does (#744).
removeChartTable(SAVINGS_TREND_TABLE_ID);
return;
}
canvas.classList.remove('hidden');
empty?.classList.add('hidden');

if (savingsTrendChart) savingsTrendChart.destroy();

// #744: summarize the plotted series and list every point as text.
const rangeText = isAllRange ? 'the last year' : `the last ${days} days`;
const latest = chartData[chartData.length - 1]!;
setChartSummary(canvas,
`Line chart of cumulative savings over ${rangeText}: ${formatCurrency(latest.y)} `
+ `as of ${formatTrendAxisTick(latest.x, interval)}, ${chartData.length} data point${chartData.length === 1 ? '' : 's'}. `
+ 'Values are in the table below the chart.');
renderChartTable(canvas, SAVINGS_TREND_TABLE_ID, {
caption: `Cumulative savings over ${rangeText}`,
columns: ['Date', 'Cumulative savings'],
rows: chartData.map(p => [formatTrendAxisTick(p.x, interval), formatCurrency(p.y)]),
});

savingsTrendChart = new Chart(canvas, {
type: 'line',
data: {
Expand Down Expand Up @@ -1193,6 +1238,7 @@ export async function loadSavingsTrendChart(): Promise<void> {
// the dashboard — hide the widget and fall back to a neutral message.
console.warn('Savings trend chart unavailable:', err);
if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; }
removeChartTable(SAVINGS_TREND_TABLE_ID);
canvas.classList.add('hidden');
if (empty) {
empty.textContent = 'Savings history is not available yet.';
Expand Down
Loading
Loading