mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
245 lines
9.3 KiB
TypeScript
245 lines
9.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { createSnippet } from '@core/snippet';
|
|
import { useSnippetStore } from '../stores/SnippetStore';
|
|
import { SnippetLibrary } from './SnippetLibrary';
|
|
|
|
// StorageMonitor (rendered at the bottom of the pane) measures storage and fetches
|
|
// an async estimate on mount; stub the whole component to nothing so its async
|
|
// setState doesn't fire outside act() and its legend text doesn't collide with
|
|
// library assertions. This suite is about the library, not the monitor.
|
|
vi.mock('./StorageMonitor', () => ({ default: () => null }));
|
|
|
|
// 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;
|
|
|
|
/** The metadata-panel name field — scoped so the new search input doesn't shadow it. */
|
|
const nameInput = () =>
|
|
document.querySelector('section[aria-label="Snippet details"] input') as HTMLInputElement;
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeEach(() => {
|
|
useSnippetStore.getState().reset();
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
/** Set a controlled input/textarea's value the way React expects, then fire input. */
|
|
function typeInto(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
|
const proto =
|
|
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
// The native value setter bypasses React 19's input value tracking so the
|
|
// synthetic input event registers as a real change.
|
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
|
|
setter.call(el, value);
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
describe('SnippetLibrary metadata panel (spec §02)', () => {
|
|
test('renders the active snippet name, comment, and linked datasets without looping', async () => {
|
|
const s = {
|
|
...createSnippet({ id: 'a', name: 'Bar chart', now: new Date('2026-01-01T00:00:00Z') }),
|
|
comment: 'a note',
|
|
datasetRefs: ['Sales'],
|
|
};
|
|
useSnippetStore.getState().hydrate([s], 'a');
|
|
|
|
// If the auto-save effect looped, this act() would throw "Maximum update depth".
|
|
await act(async () => {
|
|
root.render(<SnippetLibrary />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const name = nameInput();
|
|
const comment = container.querySelector('textarea') as HTMLTextAreaElement;
|
|
expect(name.value).toBe('Bar chart');
|
|
expect(comment.value).toBe('a note');
|
|
expect(container.textContent).toContain('Linked datasets');
|
|
expect(container.textContent).toContain('Sales');
|
|
});
|
|
|
|
test('auto-saves an inline name edit after the debounce', () => {
|
|
vi.useFakeTimers();
|
|
const s = createSnippet({ id: 'a', name: 'Old', now: new Date('2026-01-01T00:00:00Z') });
|
|
useSnippetStore.getState().hydrate([s], 'a');
|
|
|
|
act(() => {
|
|
root.render(<SnippetLibrary />);
|
|
});
|
|
|
|
const name = nameInput();
|
|
act(() => typeInto(name, 'Renamed'));
|
|
// Before the debounce fires, the store is unchanged.
|
|
expect(useSnippetStore.getState().snippets[0].name).toBe('Old');
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(500);
|
|
});
|
|
expect(useSnippetStore.getState().snippets[0].name).toBe('Renamed');
|
|
});
|
|
|
|
test('a publish-derived rename is adopted by the panel, not reverted by its auto-save', () => {
|
|
// Regression: the panel's local name state lagged a publish rename, so its
|
|
// debounced auto-save wrote the stale default back — the rename flickered
|
|
// for ~400ms in the list and then undid itself.
|
|
vi.useFakeTimers();
|
|
const s = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') }); // auto name
|
|
useSnippetStore.getState().hydrate([s], 'a');
|
|
|
|
act(() => {
|
|
root.render(<SnippetLibrary />);
|
|
});
|
|
|
|
act(() => {
|
|
useSnippetStore
|
|
.getState()
|
|
.updateDraft(
|
|
JSON.stringify({
|
|
mark: 'bar',
|
|
encoding: { x: { field: 'Region' }, y: { aggregate: 'count' } },
|
|
}),
|
|
);
|
|
useSnippetStore.getState().publish(new Date('2026-02-01T00:00:00Z'));
|
|
});
|
|
|
|
expect(nameInput().value).toBe('Bar chart of count by Region');
|
|
act(() => {
|
|
vi.advanceTimersByTime(1000); // any pending auto-save settles
|
|
});
|
|
expect(useSnippetStore.getState().snippets[0].name).toBe('Bar chart of count by Region');
|
|
});
|
|
|
|
test('a name edit in progress survives a publish rename (user text wins)', () => {
|
|
vi.useFakeTimers();
|
|
const s = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
|
|
useSnippetStore.getState().hydrate([s], 'a');
|
|
|
|
act(() => {
|
|
root.render(<SnippetLibrary />);
|
|
});
|
|
|
|
act(() => typeInto(nameInput(), 'My Chart')); // diverged, debounce pending
|
|
act(() => {
|
|
useSnippetStore
|
|
.getState()
|
|
.updateDraft(
|
|
JSON.stringify({
|
|
mark: 'bar',
|
|
encoding: { x: { field: 'Region' }, y: { aggregate: 'count' } },
|
|
}),
|
|
);
|
|
useSnippetStore.getState().publish();
|
|
});
|
|
|
|
expect(nameInput().value).toBe('My Chart'); // not clobbered by the derived name
|
|
act(() => {
|
|
vi.advanceTimersByTime(1000);
|
|
});
|
|
expect(useSnippetStore.getState().snippets[0].name).toBe('My Chart');
|
|
});
|
|
|
|
test('does not loop on a search/sort state change (render-loop guard)', async () => {
|
|
// A selector that returned a fresh filtered array would re-render forever
|
|
// (MEMORY → "Zustand stable selectors"); the component derives via useMemo.
|
|
const a = createSnippet({ id: 'a', name: 'Alpha', now: new Date('2026-01-01T00:00:00Z') });
|
|
const b = createSnippet({ id: 'b', name: 'Beta', now: new Date('2026-02-01T00:00:00Z') });
|
|
useSnippetStore.getState().hydrate([a, b], 'a');
|
|
|
|
await act(async () => {
|
|
root.render(<SnippetLibrary />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// Each store change below would throw "Maximum update depth" inside act() if
|
|
// a render loop existed. Bounded, finite settling = no loop.
|
|
await act(async () => {
|
|
useSnippetStore.getState().setSearch('beta');
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
useSnippetStore.getState().setSort('name');
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// The list reflects the final derived view (only Beta matches "beta").
|
|
const names = [...container.querySelectorAll('li')].map((li) => li.textContent ?? '');
|
|
expect(names.some((t) => t.includes('Beta'))).toBe(true);
|
|
expect(names.some((t) => t.includes('Alpha'))).toBe(false);
|
|
});
|
|
|
|
test('search filters the list and the clear control restores it + refocuses input', () => {
|
|
const a = createSnippet({ id: 'a', name: 'Alpha', now: new Date('2026-01-01T00:00:00Z') });
|
|
const b = createSnippet({ id: 'b', name: 'Beta', now: new Date('2026-02-01T00:00:00Z') });
|
|
useSnippetStore.getState().hydrate([a, b], 'a');
|
|
|
|
act(() => {
|
|
root.render(<SnippetLibrary />);
|
|
});
|
|
|
|
const search = container.querySelector('input[type="search"]') as HTMLInputElement;
|
|
act(() => typeInto(search, 'alpha'));
|
|
expect(useSnippetStore.getState().searchQuery).toBe('alpha');
|
|
|
|
let rows = [...container.querySelectorAll('li')].map((li) => li.textContent ?? '');
|
|
expect(rows.some((t) => t.includes('Alpha'))).toBe(true);
|
|
expect(rows.some((t) => t.includes('Beta'))).toBe(false);
|
|
|
|
const clear = container.querySelector('button[aria-label="Clear search"]') as HTMLButtonElement;
|
|
act(() => clear.click());
|
|
expect(useSnippetStore.getState().searchQuery).toBe('');
|
|
// Clear returns focus to the input (council SEARCH).
|
|
expect(document.activeElement).toBe(container.querySelector('input[type="search"]'));
|
|
|
|
rows = [...container.querySelectorAll('li')].map((li) => li.textContent ?? '');
|
|
expect(rows.some((t) => t.includes('Beta'))).toBe(true);
|
|
});
|
|
|
|
test('shows the no-matches empty state when a search filters everything out', () => {
|
|
const a = createSnippet({ id: 'a', name: 'Alpha', now: new Date('2026-01-01T00:00:00Z') });
|
|
useSnippetStore.getState().hydrate([a], 'a');
|
|
|
|
act(() => {
|
|
root.render(<SnippetLibrary />);
|
|
});
|
|
|
|
const search = container.querySelector('input[type="search"]') as HTMLInputElement;
|
|
act(() => typeInto(search, 'zzz-no-match'));
|
|
expect(container.textContent).toContain('No snippets match your search');
|
|
});
|
|
|
|
test('Duplicate adds an independent copy and makes it active', async () => {
|
|
const s = createSnippet({ id: 'a', name: 'Chart', now: new Date('2026-01-01T00:00:00Z') });
|
|
useSnippetStore.getState().hydrate([s], 'a');
|
|
|
|
await act(async () => {
|
|
root.render(<SnippetLibrary />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const dup = [...container.querySelectorAll('button')].find(
|
|
(b) => b.textContent === 'Duplicate',
|
|
)!;
|
|
await act(async () => {
|
|
dup.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const { snippets, activeSnippetId } = useSnippetStore.getState();
|
|
expect(snippets).toHaveLength(2);
|
|
const active = snippets.find((x) => x.id === activeSnippetId)!;
|
|
expect(active.name).toBe('Chart (copy)');
|
|
expect(active.id).not.toBe('a');
|
|
});
|
|
});
|