Add live-preview busy indicator for slow renders (M6, §04/§10)

This commit is contained in:
2026-06-07 20:01:39 +03:00
parent 800a313be2
commit 14f34712b2
5 changed files with 261 additions and 8 deletions
+87
View File
@@ -0,0 +1,87 @@
/**
* 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';
// Heavy services and sub-components the LivePreview wires — not under test here.
vi.mock('../services/chart-renderer', () => ({
renderSpec: () => Promise.resolve({ destroy() {}, resize() {} }),
}));
vi.mock('./SettingsPopover', () => ({
SettingsPopover: () => null,
SettingRow: () => null,
RangeControl: () => null,
}));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
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();
});
});