import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { createDataset } from '@core/dataset'; import { useChartBuilderStore } from '../stores/ChartBuilderStore'; import { useDatasetStore } from '../stores/DatasetStore'; import { useSnippetStore } from '../stores/SnippetStore'; import { ChartBuilderModal } from './ChartBuilderModal'; // The builder preview embeds a real Vega chart in an effect; stub the renderer so // these tests stay pure React/DOM checks. `renderSpec` is a vi.fn so a test can make // it reject (e.g. the canvas-too-large path); the mocked `ChartTooLargeError` is the // same class the component imports, so its `instanceof` check matches. The class is // declared inside the factory because vi.mock is hoisted above module-scope code. vi.mock('../services/chart-renderer', () => { class ChartTooLargeError extends Error { heightPx: number; limitPx: number; constructor(heightPx: number, limitPx: number) { super('too large'); this.name = 'ChartTooLargeError'; this.heightPx = heightPx; this.limitPx = limitPx; } } return { renderSpec: vi.fn(() => Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null, onDataChange: () => () => {}, }), ), ChartTooLargeError, }; }); // React 19 wants this flag set for act() to drive effects without warnings. (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const T = new Date('2026-06-01T00:00:00Z'); let container: HTMLDivElement; let root: Root; beforeEach(() => { useChartBuilderStore.getState().reset(); useDatasetStore.getState().reset(); useSnippetStore.getState().reset(); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); }); describe('ChartBuilderModal', () => { test('renders without an infinite update loop when the config has warnings (regression)', async () => { // Two numeric columns → default mark Point (clean). Switching to Bar makes it // "two measures on a non-scatter" → a NON-EMPTY warnings array — the exact // condition that previously looped because the warnings selector returned a // fresh array of objects on every render. The fix derives warnings via useMemo // over the stable `config` reference instead. const ds = createDataset({ name: 'Nums', data: [ { a: 1, b: 2 }, { a: 3, b: 4 }, ], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); // `add` reassigns a collision-free id (it is the id authority), so read it back. const id = useDatasetStore.getState().datasets[0].id; useChartBuilderStore.getState().init(id); useChartBuilderStore.getState().setMark('bar'); expect(useChartBuilderStore.getState().config.mark).toBe('bar'); // If the component looped, this act() would throw "Maximum update depth exceeded". await act(async () => { root.render(); await Promise.resolve(); }); expect(container.textContent).toContain('Nums'); // the dataset picker names the data expect(container.textContent).toContain('scatter'); // the guidance hint rendered }); test('a guidance hint offers a one-click fix that resolves it (actionable hints, §06)', async () => { const ds = createDataset({ name: 'Nums', data: [ { a: 1, b: 2 }, { a: 3, b: 4 }, ], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); const id = useDatasetStore.getState().datasets[0].id; useChartBuilderStore.getState().init(id); useChartBuilderStore.getState().setMark('bar'); // two measures on a bar → scatter hint await act(async () => { root.render(); await Promise.resolve(); }); // The hint renders a [Switch to Point] button (not just prose). const fixButton = Array.from(container.querySelectorAll('button')).find( (b) => b.textContent === 'Switch to Point', ); expect(fixButton).toBeDefined(); expect(container.textContent).toContain('scatter'); await act(async () => { fixButton!.click(); await Promise.resolve(); }); // Applying it switches the mark and the hint re-derives away. expect(useChartBuilderStore.getState().config.mark).toBe('point'); expect(container.textContent).not.toContain('scatter'); }); test('shows the canvas-limit message when the chart resolves too large to render', async () => { vi.useFakeTimers(); const { renderSpec, ChartTooLargeError } = await import('../services/chart-renderer'); vi.mocked(renderSpec).mockRejectedValueOnce(new ChartTooLargeError(200_000, 16_383)); const ds = createDataset({ name: 'Big', data: [ { a: 1, b: 'x' }, { a: 2, b: 'y' }, ], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); const id = useDatasetStore.getState().datasets[0].id; useChartBuilderStore.getState().init(id); await act(async () => { root.render(); await Promise.resolve(); }); // Drive the debounced render so renderSpec runs and rejects with the limit error. await act(async () => { await vi.advanceTimersByTimeAsync(400); }); expect(container.textContent).toContain('larger than the browser can draw on a canvas'); expect(container.textContent).toContain('200,000'); // the measured height vi.useRealTimers(); }); test('a complete filter row reaches the renderer as a top-level transform (1C)', async () => { vi.useFakeTimers(); const { renderSpec } = await import('../services/chart-renderer'); vi.mocked(renderSpec).mockClear(); const ds = createDataset({ name: 'Sales', data: [ { region: 'N', revenue: 100 }, { region: 'S', revenue: 50 }, ], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); const id = useDatasetStore.getState().datasets[0].id; const store = useChartBuilderStore.getState(); store.init(id); store.addFilter(); const fid = useChartBuilderStore.getState().config.filters![0].id; store.setFilterField(fid, 'revenue'); store.updateFilter(fid, { op: 'gt', value: '60' }); await act(async () => { root.render(); await Promise.resolve(); }); await act(async () => { await vi.advanceTimersByTimeAsync(400); // drive the debounced preview render }); expect(container.querySelector('button[aria-label^="Filter operator"]')).toBeTruthy(); const calls = vi.mocked(renderSpec).mock.calls; const lastSpec = calls[calls.length - 1][1] as { transform?: unknown }; expect(lastSpec.transform).toEqual([{ filter: { field: 'revenue', gt: 60 } }]); vi.useRealTimers(); }); test('the data preview discloses the dataset rows on demand (1D)', async () => { const ds = createDataset({ name: 'Sales', data: [{ region: 'North', revenue: 100 }], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); const id = useDatasetStore.getState().datasets[0].id; useChartBuilderStore.getState().init(id); await act(async () => { root.render(); await Promise.resolve(); }); const toggle = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.includes('Preview rows'), ); expect(toggle).toBeTruthy(); expect(container.querySelector('table')).toBeNull(); // collapsed by default await act(async () => { toggle!.click(); await Promise.resolve(); }); expect(container.querySelector('table')).toBeTruthy(); expect(container.textContent).toContain('region'); expect(container.textContent).toContain('North'); }); test('an invalid expression-mode filter surfaces an inline parser error (1E)', async () => { const ds = createDataset({ name: 'S', data: [{ a: 1 }], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); const id = useDatasetStore.getState().datasets[0].id; const store = useChartBuilderStore.getState(); store.init(id); store.addFilter(); const fid = useChartBuilderStore.getState().config.filters![0].id; store.setFilterMode(fid, 'expression'); store.updateFilter(fid, { expr: 'datum.a *' }); await act(async () => { root.render(); await Promise.resolve(); }); // Polite, not assertive: live per-keystroke validation uses role="status" with a // status glyph, never an assertive alert (council: APG Alert / WCAG 2.2.4). const messages = Array.from(container.querySelectorAll('[role="status"]')); const errorMsg = messages.find((n) => /nexpected|Invalid/.test(n.textContent ?? '')); expect(errorMsg).toBeTruthy(); // The expression input is linked to its message and flagged invalid. const exprInput = container.querySelector('input[aria-label="Filter expression"]'); expect(exprInput?.getAttribute('aria-invalid')).toBe('true'); expect(exprInput?.getAttribute('aria-describedby')).toBe(errorMsg?.id); }); test('the Vega expression reference shows only when an expression is in play (1E)', async () => { const ds = createDataset({ name: 'S', data: [{ a: 1 }], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); const id = useDatasetStore.getState().datasets[0].id; useChartBuilderStore.getState().init(id); await act(async () => { root.render(); await Promise.resolve(); }); const refLink = () => Array.from(container.querySelectorAll('a')).find((a) => a.textContent?.includes('Vega expression'), ); expect(refLink()).toBeUndefined(); // no expression yet → no reference link await act(async () => { useChartBuilderStore.getState().addCalculate(); // a calculated field is an expression await Promise.resolve(); }); expect(refLink()).toBeDefined(); expect(refLink()!.getAttribute('href')).toContain('vega.github.io'); }); test('clicking a field opens the channel chooser; picking a channel assigns it (field-first, 2B)', async () => { const ds = createDataset({ name: 'Shop', data: [{ region: 'E', sales: 5 }], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); const id = useDatasetStore.getState().datasets[0].id; const store = useChartBuilderStore.getState(); store.init(id); store.setChannelColumn('x', null); store.setChannelColumn('y', null); await act(async () => { root.render(); await Promise.resolve(); }); const fieldButton = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.includes('region'), ); expect(fieldButton).toBeDefined(); await act(async () => { fieldButton!.click(); await Promise.resolve(); }); // Unarmed, the click opens an explicit channel chooser (portaled to ) // rather than silently filling the first empty seat (council 2026-06-12). expect(useChartBuilderStore.getState().config.encodings.x).toBeNull(); const option = Array.from(document.body.querySelectorAll('button')).find((b) => b.textContent?.includes('Columns (X)'), ); expect(option).toBeDefined(); await act(async () => { option!.click(); await Promise.resolve(); }); expect(useChartBuilderStore.getState().config.encodings.x).toEqual({ field: 'region', type: 'nominal', }); }); test('an armed channel short-circuits the chooser: the field assigns directly (2B)', async () => { const ds = createDataset({ name: 'Shop', data: [{ region: 'E', sales: 5 }], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); const id = useDatasetStore.getState().datasets[0].id; const store = useChartBuilderStore.getState(); store.init(id); store.setChannelColumn('x', null); store.setChannelColumn('y', null); store.focusChannel('y'); await act(async () => { root.render(); await Promise.resolve(); }); // The armed state announces itself at the shelf. expect(container.textContent).toContain('Assigning to Y'); const fieldButton = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.includes('region'), ); await act(async () => { fieldButton!.click(); await Promise.resolve(); }); expect(useChartBuilderStore.getState().config.encodings.y).toEqual({ field: 'region', type: 'nominal', }); expect(useChartBuilderStore.getState().activeChannel).toBeNull(); }); test('opening a SelectControl lands focus on the selected option, not the first (regression)', async () => { const ds = createDataset({ name: 'Sales', data: [{ day: '2026-01-01', v: 1 }], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); const id = useDatasetStore.getState().datasets[0].id; const store = useChartBuilderStore.getState(); store.init(id); store.setChannelColumn('x', 'day'); // temporal → the pill offers Granularity store.setChannelTimeUnit('x', 'month'); // "Month" sits mid-list, after "None (raw)" await act(async () => { root.render(); await Promise.resolve(); }); const trigger = container.querySelector( 'button[aria-label^="Granularity for"]', ); expect(trigger).toBeTruthy(); await act(async () => { trigger!.click(); await Promise.resolve(); }); // A selector list ('[aria-current="true"], button') would return the first // button in document order — the "None (raw)" option — instead of the selection. const focused = document.activeElement as HTMLElement; expect(focused.getAttribute('aria-current')).toBe('true'); expect(focused.textContent).toContain('Month'); }); test('Colour can be switched to a constant value (the Property model, 2A/2B)', async () => { const ds = createDataset({ name: 'Shop', data: [{ region: 'E', sales: 5 }], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); const id = useDatasetStore.getState().datasets[0].id; useChartBuilderStore.getState().init(id); await act(async () => { root.render(); await Promise.resolve(); }); // The empty Colour slot offers a "Use a constant" ghost button (Colour is // first in Marks). const constButton = Array.from(container.querySelectorAll('button')).find( (b) => b.textContent === 'Use a constant', ); expect(constButton).toBeDefined(); await act(async () => { constButton!.click(); await Promise.resolve(); }); expect(useChartBuilderStore.getState().config.encodings.color?.value).toBeDefined(); // A colour picker renders for the constant. expect(container.querySelector('input[type="color"]')).not.toBeNull(); }); // The intent front door's core logic (which chip lights, what each applies, gating) // is covered in core/store; these check only the parts that live in the component — // the toolbar's roving tabindex and arrow navigation (a hand-rolled handler, not a // shared primitive), and that a click reshapes the chart. describe('intent front door (the "What do you want to show?" strip)', () => { // Two categories + one date + two measures → every intent applies; enough shape to // light a default and to exercise an applied intent (Heatmap needs two categories). const seedSuperstore = () => { const ds = createDataset({ name: 'Superstore', data: [ { region: 'E', segment: 'A', date: '2026-01-01', sales: 5, profit: 1 }, { region: 'W', segment: 'B', date: '2026-02-01', sales: 9, profit: 3 }, ], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); const id = useDatasetStore.getState().datasets[0].id; useChartBuilderStore.getState().init(id); return id; }; const chips = (): HTMLButtonElement[] => Array.from(container.querySelectorAll('[role="toolbar"] button')); test('renders one tab stop and lights the active intent (roving tabindex)', async () => { seedSuperstore(); await act(async () => { root.render(); await Promise.resolve(); }); const all = chips(); expect(all.length).toBe(7); // every intent shows (Tableau Show Me: never hidden) // Exactly one chip is in the tab order; the rest are roving (-1). expect(all.filter((b) => b.tabIndex === 0)).toHaveLength(1); // The smart default for a category+count shape is Compare, so its chip is pressed. const pressed = all.filter((b) => b.getAttribute('aria-pressed') === 'true'); expect(pressed).toHaveLength(1); expect(pressed[0].textContent).toBe('Compare'); }); test('an inapplicable intent is disabled and names its reason', async () => { // One category, one measure, no date and no second measure → Correlation/Time/ // Heatmap/Part-to-whole cannot apply. const ds = createDataset({ name: 'Thin', data: [{ region: 'E', sales: 5 }], format: 'json', source: 'inline', now: T, }); useDatasetStore.getState().add(ds); const id = useDatasetStore.getState().datasets[0].id; useChartBuilderStore.getState().init(id); await act(async () => { root.render(); await Promise.resolve(); }); const correlation = chips().find((b) => b.textContent === 'Correlation')!; expect(correlation.getAttribute('aria-disabled')).toBe('true'); expect(correlation.getAttribute('aria-label')).toMatch(/needs two number columns/); }); test('clicking an enabled chip reshapes the chart to that intent', async () => { seedSuperstore(); await act(async () => { root.render(); await Promise.resolve(); }); const heatmap = chips().find((b) => b.textContent === 'Heatmap')!; await act(async () => { heatmap.click(); await Promise.resolve(); }); expect(useChartBuilderStore.getState().config.mark).toBe('rect'); expect(useChartBuilderStore.getState().config.encodings.color).toEqual({ type: 'quantitative', aggregate: 'count', }); }); test('ArrowRight moves focus along the toolbar without applying (focus-only)', async () => { seedSuperstore(); await act(async () => { root.render(); await Promise.resolve(); }); const all = chips(); const start = all.findIndex((b) => b.tabIndex === 0); const markBefore = useChartBuilderStore.getState().config.mark; await act(async () => { all[start].focus(); all[start].dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), ); await Promise.resolve(); }); // Focus moved to the next chip; the chart is untouched (arrows navigate, Enter applies). expect(document.activeElement).toBe(all[(start + 1) % all.length]); expect(useChartBuilderStore.getState().config.mark).toBe(markBefore); }); }); });