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
34 changes: 32 additions & 2 deletions frontend/src/__tests__/ladder-timeline-filters.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { LadderEvent } from '../api/ladder';
import { emptyLadderFilters, filterLadderEvents, ladderFilterOptions, ladderTotal, ladderEditReason, ladderLabel, formatLadderAmount, formatLadderDateTime } from '../ladder-timeline-filters';
import { emptyLadderFilters, filterLadderEvents, ladderFilterOptions, ladderTotal, ladderEditReason, ladderLabel, formatLadderAmount, formatLadderDateTime, ladderCumulative, ladderNotCountedNote } from '../ladder-timeline-filters';

const event: LadderEvent = {
id: 'one', config_id: 'config', run_id: 'run', layer_type: 'compute-sp', term: '1yr',
Expand Down Expand Up @@ -28,7 +28,7 @@ test('six decimal totals and UTC date boundaries stay exact', () => {

test('eligibility covers status, execution, deadline, and unavailable revision API', () => {
expect(ladderEditReason(event)).toBeUndefined();
expect(ladderEditReason({ ...event, run_status: 'awaiting_approval' })).toContain('awaiting_approval');
expect(ladderEditReason({ ...event, run_status: 'awaiting_approval' })).toContain('awaiting approval');
expect(ladderEditReason({ ...event, status: 'completed' })).toContain('completed');
expect(ladderEditReason({ ...event, execution_id: 'execution' })).toContain('execution');
expect(ladderEditReason(event, Date.parse(event.scheduled_date))).toContain('passed');
Expand Down Expand Up @@ -61,3 +61,33 @@ test('dates render the UTC calendar day and time regardless of browser zone', ()
expect(formatLadderDateTime('2030-01-02T23:30:00-05:00')).toBe('Jan 3, 2030, 04:30 UTC');
expect(formatLadderDateTime('garbage')).toBe('garbage');
});

test('sub-0.0001 amounts are not shown as $0.0000 and edit reasons use readable run status (follow-ups to #697)', () => {
expect(formatLadderAmount('0.000040')).toBe('<$0.0001/hr');
expect(formatLadderAmount('0.000100')).toBe('$0.0001/hr');
expect(ladderEditReason({ ...event, run_status: 'awaiting_approval' })).toBe('Run is awaiting approval.');
expect(ladderEditReason({ ...event, status: 'cancelled' })).toBe('Tranche is cancelled.');
});

// FIXTURE: hand-written tranches.
test('ladderCumulative carries each layer forward on shared times and keeps zero-amount tranches', () => {
const a = { ...event, id: 'a', amount_usd_hr: '0.500000', scheduled_date: '2030-01-01T00:00:00Z' };
const b = { ...event, id: 'b', layer_type: 'ec2-instance-sp', amount_usd_hr: '1.000000', scheduled_date: '2030-02-01T00:00:00Z' };
const c = { ...event, id: 'c', layer_type: 'ec2-instance-sp', amount_usd_hr: '0.000000', scheduled_date: '2030-02-01T00:00:00Z' };
const result = ladderCumulative([a, b, c], 2000000n);
expect(result.times).toEqual([Date.parse(a.scheduled_date), Date.parse(b.scheduled_date)]);
expect(result.layers).toEqual([{ layer: 'compute-sp', units: [500000n, 500000n] }, { layer: 'ec2-instance-sp', units: [0n, 1000000n] }]);
expect(result.totals).toEqual([2500000n, 3500000n]);
expect([...result.rowTotals]).toEqual([['a', 2500000n], ['b', 3500000n], ['c', 3500000n]]);
});

// FIXTURE: hand-written statuses; the gate repro was scheduled 1.0 + cancelled 5.0 + failed 7.0 showing $13.00/hr instead of $1.00/hr.
test('ladderCumulative counts only scheduled, fired and completed tranches (#698 gate)', () => {
const at = (id: string, status: string, amount: string, day: number) => ({ ...event, id, status, amount_usd_hr: amount, scheduled_date: `2030-01-0${day}T00:00:00Z` });
const rows = [at('s', 'scheduled', '1.000000', 1), at('c', 'cancelled', '5.000000', 2), at('f', 'failed', '7.000000', 3), at('x', 'weird', '9.000000', 4), at('fi', 'fired', '2.000000', 5), at('d', 'completed', '3.000000', 6)];
const result = ladderCumulative(rows, 0n);
expect(result.totals).toEqual([1000000n, 3000000n, 6000000n]);
expect([...result.rowTotals]).toEqual([['s', 1000000n], ['c', null], ['f', null], ['x', null], ['fi', 3000000n], ['d', 6000000n]]);
expect(rows.map(ladderNotCountedNote)).toEqual([undefined, 'Not counted', 'Not counted', 'Not counted (unknown status)', undefined, undefined]);
expect(ladderCumulative([at('c', 'cancelled', '5.000000', 1)], 0n).times).toEqual([]);
});
103 changes: 98 additions & 5 deletions frontend/src/__tests__/ladder-timeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
jest.mock('chart.js', () => ({ Chart: Object.assign(jest.fn().mockImplementation(() => ({ destroy: jest.fn() })), { register: jest.fn() }), registerables: [] }));
jest.mock('chart.js', () => ({ Chart: Object.assign(jest.fn().mockImplementation((_canvas: unknown, config: { data: unknown; options: unknown }) => ({ destroy: jest.fn(), update: jest.fn(), data: config.data, options: config.options })), { register: jest.fn() }), registerables: [] }));
jest.mock('../api/accounts', () => ({ listAccountsMinimal: jest.fn() }));
jest.mock('../api/ladder', () => ({ getLadderEvents: jest.fn(), getLadderRuns: jest.fn(), amendLadderEvent: jest.fn() }));
jest.mock('../permissions', () => ({ canAccess: jest.fn(() => true) }));
Expand All @@ -12,8 +12,12 @@ import { Chart } from 'chart.js';
const event: LadderEvent = { id: 'one', config_id: 'config', run_id: 'run', layer_type: 'compute-sp', term: '1yr', payment_option: 'no-upfront', status: 'scheduled', run_status: 'planned', revision: 0, amount_usd_hr: '1.000000', scheduled_date: '2030-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z' };
const run: LadderRun = { id: 'run', config_id: 'config', status: 'planned', started_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z', baseline_usd_hr: null, existing_usd_hr: null, target_usd_hr: null, gap_usd_hr: null, total_hourly_commit: '1.000000', actions: [] };

// FIXTURE: stand-ins for the design tokens in styles/base.css (jsdom loads no CSS).
const tokenColors: Record<string, string> = { '--cudly-primary': '#111111', '--cudly-success': '#222222', '--cudly-warn-fg': '#333333', '--cudly-error': '#444444', '--cudly-info': '#555555', '--cudly-text': '#666666', '--cudly-text-muted': '#777777', '--cudly-text-subtle': '#888888' };

beforeEach(() => {
jest.clearAllMocks();
Object.entries(tokenColors).forEach(([name, value]) => document.documentElement.style.setProperty(name, value));
jest.mocked(canAccess).mockReturnValue(true);
document.body.innerHTML = '<section id="timeline"></section>';
jest.mocked(listAccountsMinimal).mockResolvedValue([{ id: 'account', name: 'Account', external_id: '123', provider: 'aws' }]);
Expand Down Expand Up @@ -123,12 +127,14 @@ test('graph legend filters share visible counts and stable layer colors', async
await initLadderTimeline(document.getElementById('timeline')!, [], false);
const chartConfig = jest.mocked(Chart).mock.calls[0]?.[1];
const legendClick = chartConfig?.options?.plugins?.legend?.onClick as (...args: unknown[]) => void;
const color = chartConfig?.data.datasets[1]?.backgroundColor;
expect(color).toBe('#0891b2');
expect(chartConfig?.data.datasets.map(d => d.backgroundColor)).toEqual(['#111111', '#222222']);
legendClick({}, { datasetIndex: 1 });
expect(document.querySelector('[data-summary]')!.textContent).toContain('1 of 2');
expect(document.querySelector<HTMLSelectElement>('[data-filter="layer_type"]')!.value).toBe('ec2-instance-sp');
expect(jest.mocked(Chart).mock.calls[1]?.[1].data.datasets[0]?.backgroundColor).toBe(color);
expect(Chart).toHaveBeenCalledTimes(1);
const instance = jest.mocked(Chart).mock.results[0]!.value as { update: jest.Mock; data: { datasets: { backgroundColor: string }[] } };
expect(instance.update).toHaveBeenCalledTimes(1);
expect(instance.data.datasets.map(d => d.backgroundColor)).toEqual(['#222222']);
});

test('save errors retain draft and invalid dates do not reach the API', async () => {
Expand Down Expand Up @@ -217,7 +223,7 @@ test('table, filters, summary, snapshot and tooltip show formatted values, not r
expect(snapshot).toContain('baseline $0.00/hr, existing unavailable, target $12.50/hr');
const chartCalls = jest.mocked(Chart).mock.calls;
const tooltip = chartCalls[chartCalls.length - 1]?.[1].options?.plugins?.tooltip?.callbacks?.label as (c: { datasetIndex: number; dataIndex: number }) => string;
expect(tooltip({ datasetIndex: 0, dataIndex: 0 })).toBe('Jan 2, 2030, 23:30 UTC: $0.1235/hr (Convertible RI, Scheduled)');
expect(tooltip({ datasetIndex: 0, dataIndex: 0 })).toBe('Convertible RI: $0.1235/hr cumulative, on-demand-equivalent $/hr');
});

// FIXTURE: hand-written editor input.
Expand All @@ -233,3 +239,90 @@ test('editor header and run ceiling are formatted while the amount input keeps t
expect(text).toContain('Current run total after this change: $0.1235/hr');
expect((document.getElementById('ladder-event-amount') as HTMLInputElement).value).toBe('0.123456');
});

// FIXTURE: hand-written tranches: two layers, one run with existing 2.0 and target 5.0 on-demand-equivalent $/hr.
const cumulativeEvents = [
{ ...event, id: 'a', amount_usd_hr: '0.500000', scheduled_date: '2030-01-01T00:00:00Z' },
{ ...event, id: 'b', layer_type: 'ec2-instance-sp', amount_usd_hr: '1.000000', scheduled_date: '2030-02-01T00:00:00Z' },
];

test('chart is a stepped stacked line of cumulative commitment with a dashed target, in on-demand-equivalent units (#698)', async () => {
jest.mocked(getLadderEvents).mockResolvedValue(cumulativeEvents);
jest.mocked(getLadderRuns).mockResolvedValue([{ ...run, existing_usd_hr: '2.000000', target_usd_hr: '5.000000' }]);
await initLadderTimeline(document.getElementById('timeline')!, [], false);
const runFilter = document.querySelector<HTMLSelectElement>('[data-filter="run_id"]')!;
runFilter.value = 'run'; runFilter.dispatchEvent(new Event('change'));
const instance = jest.mocked(Chart).mock.results[0]!.value as { data: { datasets: Record<string, unknown>[] } };
expect(instance.data.datasets.map(d => [d['label'], (d['data'] as { y: number }[]).map(p => p.y)])).toEqual([
['Existing commitment', [2, 2]], ['Compute Savings Plan', [0.5, 0.5]], ['EC2 Instance Savings Plan', [0, 1]], ['Target', [5, 5]],
]);
expect(instance.data.datasets.every(d => d['stepped'] === 'before')).toBe(true);
expect(instance.data.datasets[3]).toMatchObject({ borderDash: [6, 4], fill: false, stack: 'target' });
const config = jest.mocked(Chart).mock.calls[0]![1] as { type: string };
expect(config.type).toBe('line');
const rows = Array.from(document.querySelectorAll('#timeline tbody tr')).map(row => row.textContent ?? '');
expect(rows[0]).toContain('$2.50/hr');
expect(rows[1]).toContain('$3.50/hr');
expect(document.querySelector('#timeline thead')!.textContent).toContain('Cumulative (on-demand-equivalent $/hr)');
const label = document.querySelector('canvas')!.getAttribute('aria-label')!;
expect(label).toContain('including the existing commitment snapshot');
expect(label).toContain('$2.50/hr on Jan 1, 2030 rising to $3.50/hr on Feb 1, 2030');
expect(label).toContain('Target $5.00/hr');
});

test('without a selected run the baseline is zero, labelled planned additions only, and no target line is drawn (#698)', async () => {
jest.mocked(getLadderEvents).mockResolvedValue(cumulativeEvents);
await initLadderTimeline(document.getElementById('timeline')!, [], false);
const instance = jest.mocked(Chart).mock.results[0]!.value as { data: { datasets: { label: string }[] } };
expect(instance.data.datasets.map(d => d.label)).toEqual(['Compute Savings Plan', 'EC2 Instance Savings Plan']);
expect(document.querySelector('canvas')!.getAttribute('aria-label')).toContain('planned additions only');
expect(document.querySelector('#timeline tbody tr')!.textContent).toContain('$0.5000/hr');
});

test('an empty selection never constructs the chart, and a later non-empty one does (#698)', async () => {
jest.mocked(getLadderEvents).mockResolvedValue([]);
await initLadderTimeline(document.getElementById('timeline')!, [], false);
expect(Chart).not.toHaveBeenCalled();
expect(document.querySelector('.ladder-timeline-chart')!.hasAttribute('hidden')).toBe(true);
});

test('a missing design token fails loudly instead of painting a default color (#698)', async () => {
document.documentElement.style.removeProperty('--cudly-primary');
jest.mocked(getLadderEvents).mockResolvedValue(cumulativeEvents);
await initLadderTimeline(document.getElementById('timeline')!, [], false);
expect(document.querySelector('[data-state]')!.textContent).toContain('Missing CSS token --cudly-primary');
});

// FIXTURE: hand-written tranches reproducing the gate finding (status filter cleared so all statuses are visible).
test('cancelled, failed and unknown tranches stay visible but are not counted in chart, summary or table (#698 gate)', async () => {
const row = (id: string, status: string, amount: string, day: number) => ({ ...event, id, status, amount_usd_hr: amount, scheduled_date: `2030-01-0${day}T00:00:00Z` });
jest.mocked(getLadderEvents).mockResolvedValue([row('s', 'scheduled', '1.000000', 1), row('c', 'cancelled', '5.000000', 2), row('f', 'failed', '7.000000', 3), row('x', 'weird', '9.000000', 4)]);
await initLadderTimeline(document.getElementById('timeline')!, [], false);
const status = document.querySelector<HTMLSelectElement>('[data-filter="status"]')!;
status.value = ''; status.dispatchEvent(new Event('change'));
const instance = jest.mocked(Chart).mock.results[0]!.value as { data: { datasets: { data: { y: number }[] }[] } };
expect(instance.data.datasets.map(d => d.data.map(p => p.y))).toEqual([[1]]);
const cells = Array.from(document.querySelectorAll('#timeline tbody tr')).map(r => r.querySelectorAll('td')[5]!.textContent);
expect(cells).toEqual(['$1.00/hr', 'Not counted', 'Not counted', 'Not counted (unknown status)']);
const label = document.querySelector('canvas')!.getAttribute('aria-label')!;
expect(label).toContain('rising to $1.00/hr');
expect(label).toContain('Counts scheduled, fired and completed tranches');
expect(document.querySelector('#timeline caption')!.textContent).toContain('Counts scheduled, fired and completed tranches');
const chartConfig = jest.mocked(Chart).mock.calls[0]![1] as unknown as { data: { datasets: { pointStyle: string }[] }; options: { plugins: { tooltip: { callbacks: { label: (c: { datasetIndex: number; dataIndex: number }) => string } } } } };
expect(chartConfig.options.plugins.tooltip.callbacks.label({ datasetIndex: 0, dataIndex: 0 })).toBe('Compute Savings Plan: $1.00/hr cumulative, on-demand-equivalent $/hr');
});

test('layers are told apart by point shape as well as color, and a selection with no counted tranche draws no chart (#698 gate)', async () => {
jest.mocked(getLadderEvents).mockResolvedValue([{ ...event, id: 'a', amount_usd_hr: '1.000000', scheduled_date: '2030-01-01T00:00:00Z' }, { ...event, id: 'b', layer_type: 'ec2-instance-sp', scheduled_date: '2030-01-02T00:00:00Z' }]);
await initLadderTimeline(document.getElementById('timeline')!, [], false);
const config = jest.mocked(Chart).mock.calls[0]![1] as { data: { datasets: { pointStyle: string }[] } };
expect(new Set(config.data.datasets.map(d => d.pointStyle)).size).toBe(2);
jest.mocked(getLadderEvents).mockResolvedValue([{ ...event, status: 'cancelled' }]);
document.body.innerHTML = '<section id="timeline"></section>';
jest.mocked(Chart).mockClear();
await initLadderTimeline(document.getElementById('timeline')!, [], false);
const status = document.querySelector<HTMLSelectElement>('[data-filter="status"]')!;
status.value = ''; status.dispatchEvent(new Event('change'));
expect(Chart).not.toHaveBeenCalled();
expect(document.querySelector('.ladder-timeline-chart')!.hasAttribute('hidden')).toBe(true);
});
Loading
Loading