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) fetches an async storage // estimate on mount; stub it so its setState doesn't fire outside act() and add // test noise. This suite is about the library, not the monitor. vi.mock('../infrastructure/storage-estimate', () => ({ readStorageEstimate: () => Promise.resolve({ available: false }), })); // 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(); 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(); }); 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('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(); 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(); }); 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(); }); 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('shows the empty-library state when there are no snippets', () => { useSnippetStore.getState().hydrate([], null); act(() => { root.render(); }); expect(container.textContent).toContain('No snippets yet'); }); 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(); 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'); }); });