mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
189 lines
7.1 KiB
TypeScript
189 lines
7.1 KiB
TypeScript
/**
|
|
* LivePreview — busy overlay guard (spec §04; arch §10.2).
|
|
*
|
|
* The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco); the
|
|
* busy OVERLAY itself is purely a function of `PreviewStore.busy`. These tests
|
|
* set that flag directly and assert the DOM result — no timing, no mocking of the
|
|
* async render path.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { usePreviewStore } from '../stores/PreviewStore';
|
|
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[],
|
|
}));
|
|
vi.mock('../services/chart-renderer', () => ({
|
|
renderSpec: (node: HTMLElement) => {
|
|
const id = ++H.calls;
|
|
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() {},
|
|
});
|
|
});
|
|
});
|
|
},
|
|
}));
|
|
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;
|
|
usePreviewStore.setState({ error: null, busy: false });
|
|
useSnippetStore.getState().reset();
|
|
useDatasetStore.getState().reset();
|
|
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
act(() => root.render(<LivePreview />));
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
usePreviewStore.setState({ error: null, busy: false });
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('LivePreview busy overlay', () => {
|
|
test('does not render the busy overlay when busy=false', () => {
|
|
// The overlay element should not be in the DOM at all during normal operation.
|
|
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
|
});
|
|
|
|
test('renders the busy overlay when PreviewStore.busy=true', () => {
|
|
act(() => usePreviewStore.setState({ busy: true }));
|
|
const overlay = container.querySelector('[aria-hidden="true"]');
|
|
expect(overlay).not.toBeNull();
|
|
});
|
|
|
|
test('overlay carries a visible label for sighted users', () => {
|
|
act(() => usePreviewStore.setState({ busy: true }));
|
|
const label = container.querySelector('[aria-hidden="true"]')?.textContent;
|
|
expect(label).toMatch(/rendering/i);
|
|
});
|
|
|
|
test('the preview body carries aria-busy=true when busy', () => {
|
|
act(() => usePreviewStore.setState({ busy: true }));
|
|
// The body element has aria-busy when the store says busy.
|
|
const busyEl = container.querySelector('[aria-busy="true"]');
|
|
expect(busyEl).not.toBeNull();
|
|
});
|
|
|
|
test('aria-busy is absent when busy=false (no aria-busy="false" noise)', () => {
|
|
// aria-busy="false" is technically valid but needlessly verbose; we omit it.
|
|
expect(container.querySelector('[aria-busy]')).toBeNull();
|
|
});
|
|
|
|
test('overlay disappears when busy returns to false', () => {
|
|
act(() => usePreviewStore.setState({ busy: true }));
|
|
expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull();
|
|
act(() => usePreviewStore.setState({ busy: false }));
|
|
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
|
});
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|