mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add snippet-library search, sort, empty states, storage monitor (M6, §02/§09D)
This commit is contained in:
@@ -5,9 +5,20 @@ 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;
|
||||
|
||||
@@ -51,7 +62,7 @@ describe('SnippetLibrary metadata panel (spec §02)', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const name = container.querySelector('input') as HTMLInputElement;
|
||||
const name = nameInput();
|
||||
const comment = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
expect(name.value).toBe('Bar chart');
|
||||
expect(comment.value).toBe('a note');
|
||||
@@ -68,7 +79,7 @@ describe('SnippetLibrary metadata panel (spec §02)', () => {
|
||||
root.render(<SnippetLibrary />);
|
||||
});
|
||||
|
||||
const name = container.querySelector('input') as HTMLInputElement;
|
||||
const name = nameInput();
|
||||
act(() => typeInto(name, 'Renamed'));
|
||||
// Before the debounce fires, the store is unchanged.
|
||||
expect(useSnippetStore.getState().snippets[0].name).toBe('Old');
|
||||
@@ -79,6 +90,85 @@ describe('SnippetLibrary metadata panel (spec §02)', () => {
|
||||
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(<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('shows the empty-library state when there are no snippets', () => {
|
||||
useSnippetStore.getState().hydrate([], null);
|
||||
|
||||
act(() => {
|
||||
root.render(<SnippetLibrary />);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user