/** * LivePreview — busy overlay + render serialization (spec §04; arch §10.2). * * The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco), so * `renderSpec` is mocked to park each embed in `H.pending` — a test decides when * embeds settle. Render status (`error`/`busy`) is the pane's own local state, so * the busy overlay is driven through its real path — a render left in flight past * the ~1s timer — not by poking a flag. */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { chartConfigForSelection } from '@core/vega-themes'; import { useAppStore } from '../stores/AppStore'; import { useSnippetStore } from '../stores/SnippetStore'; import { useDatasetStore } from '../stores/DatasetStore'; import { LivePreview } from './LivePreview'; // Controllable renderSpec: each call gets an id and parks a resolver in `H.pending` // so a test can decide exactly when (and in what order) embeds settle — the only // way to exercise the overlapping-render serialization deterministically. `destroy` // records its id and clears the node, mirroring the real handle (which calls // node.replaceChildren()), so a wrongful blank would be observable. const H = vi.hoisted(() => ({ calls: 0, pending: [] as Array<() => void>, destroyed: [] as number[], configs: [] as unknown[], })); vi.mock('../services/chart-renderer', () => ({ renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => { const id = ++H.calls; H.configs.push(config); return new Promise((resolve) => { H.pending.push(() => { node.replaceChildren(); // a real embed wipes then rebuilds the host const mark = document.createElement('div'); mark.dataset.render = String(id); node.appendChild(mark); resolve({ destroy() { node.replaceChildren(); H.destroyed.push(id); }, resize() {}, inspectData: () => null, onDataChange: () => () => {}, }); }); }); }, })); vi.mock('./SettingsPopover', () => ({ SettingsPopover: () => null, SettingRow: () => null, RangeControl: () => null, })); // Isolate the busy-overlay assertions (which locate the overlay by its // `aria-hidden="true"`) from the export control's own decorative icon: the // per-chart export is a sibling header control, mocked out like SettingsPopover. vi.mock('./ChartExport', () => ({ ChartExport: () => null })); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; let container: HTMLDivElement; let root: Root; beforeEach(() => { H.calls = 0; H.pending.length = 0; H.destroyed.length = 0; H.configs.length = 0; useSnippetStore.getState().reset(); useDatasetStore.getState().reset(); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); act(() => root.render()); }); afterEach(() => { act(() => root.unmount()); container.remove(); vi.clearAllMocks(); }); describe('LivePreview busy overlay', () => { const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms))); // The overlay is the aria-hidden element carrying the "Rendering…" label — a // bare [aria-hidden] query would also match decorative bits of the header // controls (e.g. the chart-theme select's caret). const overlay = () => [...container.querySelectorAll('[aria-hidden="true"]')].find((el) => /rendering/i.test(el.textContent ?? ''), ) ?? null; // Start a render and leave it parked in `H.pending`; advancing past the debounce // and the ~1s busy timer flips `busy` on — the real (and only) path now that it // is local state. const startSlowRender = async () => { act(() => { useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' }); }); await tick(); }; test('no overlay and no aria-busy before a render is in flight', () => { expect(overlay()).toBeNull(); // aria-busy="false" is technically valid but needlessly verbose; we omit it. expect(container.querySelector('[aria-busy]')).toBeNull(); }); test('a render in flight past ~1s shows the overlay and sets aria-busy', async () => { vi.useFakeTimers(); try { await startSlowRender(); expect(overlay()).not.toBeNull(); expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); } finally { vi.useRealTimers(); } }); test('the overlay clears once the render settles', async () => { vi.useFakeTimers(); try { await startSlowRender(); expect(overlay()).not.toBeNull(); act(() => H.pending[0]()); // the parked embed resolves → busy cleared on settle await tick(0); expect(overlay()).toBeNull(); } finally { vi.useRealTimers(); } }); }); describe('LivePreview render serialization', () => { /** Drive the debounced render effect to completion under fake timers. */ const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms))); test('a second render waits for the in-flight one, which is finalized when superseded', async () => { vi.useFakeTimers(); try { // Render A: valid, ref-less spec so prepareSpecForRender passes and we reach // renderSpec (which now parks, awaiting our resolver). act(() => { useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' }); }); await tick(); expect(H.calls).toBe(1); // A invoked renderSpec and is in flight // Render B arrives while A is still embedding. It must NOT start a second // embed on the shared node — it queues behind A's lock. act(() => { useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"bar"}' }); }); await tick(); expect(H.calls).toBe(1); // still only A's embed; B is serialized behind it // A settles. It was superseded by B, so its handle is finalized (destroy), // and only now does B acquire the lock and start its embed. act(() => H.pending[0]()); await tick(0); expect(H.destroyed).toContain(1); // superseded A finalized expect(H.calls).toBe(2); // B now embeds // B settles and becomes the live chart — never destroyed, and its node // content survives (A's finalize ran before B drew, so nothing blanked it). act(() => H.pending[1]()); await tick(0); expect(H.destroyed).not.toContain(2); expect(container.querySelector('[data-render="2"]')).not.toBeNull(); } finally { vi.useRealTimers(); } }); test('clearing the editor invalidates a pending render (no stale redraw)', async () => { vi.useFakeTimers(); try { act(() => { useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' }); }); await tick(); expect(H.calls).toBe(1); // render A in flight // Editor cleared while A embeds. The clear advances the generation token, so // when A finally resolves it knows it is stale and finalizes instead of // committing — the pane ends up empty, not showing A's chart. act(() => { useSnippetStore.setState({ draftText: '' }); }); await tick(); act(() => H.pending[0]()); await tick(0); expect(H.destroyed).toContain(1); // stale A finalized, not committed expect(container.querySelector('[data-render="1"]')).toBeNull(); } finally { vi.useRealTimers(); } }); }); describe('LivePreview chart theme', () => { const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms))); test('the selected chart theme decides the config passed to renderSpec', async () => { vi.useFakeTimers(); try { act(() => { useAppStore.setState({ chartTheme: 'stock', uiTheme: 'dark' }); useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' }); }); await tick(); act(() => H.pending[0]()); await tick(0); // Stock = inject nothing; vega-lite's own defaults apply. expect(H.configs[0]).toEqual({}); // Switching the theme re-renders with the new config (no text change needed). act(() => useAppStore.setState({ chartTheme: 'astrolabe' })); await tick(); act(() => H.pending[1]()); await tick(0); expect(H.configs[1]).toEqual(chartConfigForSelection('astrolabe', 'dark')); } finally { vi.useRealTimers(); act(() => useAppStore.setState({ chartTheme: 'astrolabe', uiTheme: 'light' })); } }); });