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:
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createSnippet, type Snippet } from './snippet';
|
||||
import {
|
||||
filterAndSortSnippets,
|
||||
snippetMatchesQuery,
|
||||
snippetSortSize,
|
||||
sortSnippets,
|
||||
type SortState,
|
||||
} from './snippet-sort';
|
||||
|
||||
/** Build a snippet with the fields the sort/search cares about. */
|
||||
function snip(over: Partial<Snippet> & { id: string }): Snippet {
|
||||
const base = createSnippet({ id: over.id, now: new Date('2026-01-01T00:00:00Z') });
|
||||
return { ...base, ...over };
|
||||
}
|
||||
|
||||
describe('sortSnippets · fields', () => {
|
||||
const a = snip({
|
||||
id: 'a',
|
||||
name: 'banana',
|
||||
created: '2026-01-03T00:00:00Z',
|
||||
modified: '2026-01-05T00:00:00Z',
|
||||
spec: '{"x":1}',
|
||||
});
|
||||
const b = snip({
|
||||
id: 'b',
|
||||
name: 'Apple',
|
||||
created: '2026-01-01T00:00:00Z',
|
||||
modified: '2026-01-09T00:00:00Z',
|
||||
spec: '{"longer":true,"value":2}',
|
||||
});
|
||||
const c = snip({
|
||||
id: 'c',
|
||||
name: 'cherry',
|
||||
created: '2026-01-08T00:00:00Z',
|
||||
modified: '2026-01-02T00:00:00Z',
|
||||
spec: '{}',
|
||||
});
|
||||
const all = [a, b, c];
|
||||
|
||||
const order = (s: SortState) => sortSnippets(all, s).map((x) => x.id);
|
||||
|
||||
test('modified desc / asc', () => {
|
||||
expect(order({ sortBy: 'modified', sortOrder: 'desc' })).toEqual(['b', 'a', 'c']);
|
||||
expect(order({ sortBy: 'modified', sortOrder: 'asc' })).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('created desc / asc', () => {
|
||||
expect(order({ sortBy: 'created', sortOrder: 'desc' })).toEqual(['c', 'a', 'b']);
|
||||
expect(order({ sortBy: 'created', sortOrder: 'asc' })).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('name asc / desc — case-insensitive (Apple sorts before banana)', () => {
|
||||
expect(order({ sortBy: 'name', sortOrder: 'asc' })).toEqual(['b', 'a', 'c']);
|
||||
expect(order({ sortBy: 'name', sortOrder: 'desc' })).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('size sorts by published-spec character length', () => {
|
||||
expect(snippetSortSize(c)).toBeLessThan(snippetSortSize(a));
|
||||
expect(snippetSortSize(a)).toBeLessThan(snippetSortSize(b));
|
||||
expect(order({ sortBy: 'size', sortOrder: 'asc' })).toEqual(['c', 'a', 'b']);
|
||||
expect(order({ sortBy: 'size', sortOrder: 'desc' })).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('does not mutate the input array', () => {
|
||||
const input = [a, b, c];
|
||||
sortSnippets(input, { sortBy: 'name', sortOrder: 'asc' });
|
||||
expect(input.map((x) => x.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortSnippets · stable tiebreak', () => {
|
||||
test('equal keys break by id deterministically', () => {
|
||||
const ts = '2026-01-01T00:00:00Z';
|
||||
const x = snip({ id: 'x', modified: ts });
|
||||
const y = snip({ id: 'y', modified: ts });
|
||||
const z = snip({ id: 'z', modified: ts });
|
||||
// Whatever the input order, equal modified times resolve by id ascending.
|
||||
expect(
|
||||
sortSnippets([z, x, y], { sortBy: 'modified', sortOrder: 'asc' }).map((s) => s.id),
|
||||
).toEqual(['x', 'y', 'z']);
|
||||
expect(
|
||||
sortSnippets([y, z, x], { sortBy: 'modified', sortOrder: 'asc' }).map((s) => s.id),
|
||||
).toEqual(['x', 'y', 'z']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snippetMatchesQuery', () => {
|
||||
const s = snip({
|
||||
id: 'a',
|
||||
name: 'Sales Bar Chart',
|
||||
comment: 'Quarterly revenue',
|
||||
draftSpec: '{"mark":"bar","encoding":{"x":{"field":"category"}}}',
|
||||
});
|
||||
|
||||
test('matches across name, comment, and spec content — case-insensitive', () => {
|
||||
expect(snippetMatchesQuery(s, 'sales')).toBe(true); // name, lowercased query
|
||||
expect(snippetMatchesQuery(s, 'REVENUE')).toBe(true); // comment, uppercased query
|
||||
expect(snippetMatchesQuery(s, 'category')).toBe(true); // inside the spec text
|
||||
expect(snippetMatchesQuery(s, 'bar')).toBe(true);
|
||||
});
|
||||
|
||||
test('empty / whitespace query matches everything', () => {
|
||||
expect(snippetMatchesQuery(s, '')).toBe(true);
|
||||
expect(snippetMatchesQuery(s, ' ')).toBe(true);
|
||||
});
|
||||
|
||||
test('no match returns false', () => {
|
||||
expect(snippetMatchesQuery(s, 'pie')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAndSortSnippets', () => {
|
||||
const a = snip({
|
||||
id: 'a',
|
||||
name: 'Alpha',
|
||||
comment: '',
|
||||
draftSpec: '{}',
|
||||
modified: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
const b = snip({
|
||||
id: 'b',
|
||||
name: 'Beta',
|
||||
comment: 'alpha note',
|
||||
draftSpec: '{}',
|
||||
modified: '2026-01-02T00:00:00Z',
|
||||
});
|
||||
const c = snip({
|
||||
id: 'c',
|
||||
name: 'Gamma',
|
||||
comment: '',
|
||||
draftSpec: '{}',
|
||||
modified: '2026-01-03T00:00:00Z',
|
||||
});
|
||||
|
||||
test('filters then sorts', () => {
|
||||
// "alpha" matches a (name) and b (comment); modified-desc puts b first.
|
||||
expect(
|
||||
filterAndSortSnippets([a, b, c], 'alpha', { sortBy: 'modified', sortOrder: 'desc' }).map(
|
||||
(x) => x.id,
|
||||
),
|
||||
).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
test('empty query returns all, sorted', () => {
|
||||
expect(
|
||||
filterAndSortSnippets([a, b, c], '', { sortBy: 'modified', sortOrder: 'asc' }).map(
|
||||
(x) => x.id,
|
||||
),
|
||||
).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('no matches returns an empty array', () => {
|
||||
expect(filterAndSortSnippets([a, b, c], 'zzz', { sortBy: 'name', sortOrder: 'asc' })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Snippet sort + search — pure library-ordering logic (spec §02 → Sort, Search).
|
||||
*
|
||||
* Portable core: no browser APIs, no React. The library's filter+sort pipeline
|
||||
* lives here so it is unit-tested in isolation and the component can derive its
|
||||
* view with a plain `useMemo` over primitive store values (snippets, query,
|
||||
* sortBy, sortOrder) — never inside a Zustand selector that builds a fresh array
|
||||
* (which would loop the app; see MEMORY → "Zustand stable selectors").
|
||||
*
|
||||
* Council resolution (recorded in docs/architecture/10):
|
||||
* - SORT: fields Modified / Created / Name / Size; re-selecting the active field
|
||||
* flips direction, a different field resets to descending (spec §02).
|
||||
* - SEARCH: case-insensitive substring across name + comment + spec content; we
|
||||
* match the **draft** spec text (`draftSpec`) — the working version the user is
|
||||
* editing and what the spec §02 calls "the current working/draft spec text".
|
||||
*/
|
||||
|
||||
import type { Snippet } from './snippet';
|
||||
|
||||
/** The four sortable fields (spec §02 → Sort). */
|
||||
export type SortBy = 'modified' | 'created' | 'name' | 'size';
|
||||
|
||||
/** Sort direction. */
|
||||
export type SortOrder = 'asc' | 'desc';
|
||||
|
||||
/** The library's default ordering: newest changes first (spec §02 → Sort). */
|
||||
export const DEFAULT_SORT_BY: SortBy = 'modified';
|
||||
export const DEFAULT_SORT_ORDER: SortOrder = 'desc';
|
||||
|
||||
export interface SortState {
|
||||
sortBy: SortBy;
|
||||
sortOrder: SortOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Size" of a snippet for sorting (spec §02 → "Size sorts by stored snippet
|
||||
* size"). Defined as the **character length of the published spec text** — a
|
||||
* stable storage proxy: it's the persisted bytes-ish footprint of the record's
|
||||
* main payload, doesn't fluctuate with un-published in-progress typing, and needs
|
||||
* no `TextEncoder`. (The library *row* displays the draft's UTF-8 size for an
|
||||
* at-a-glance hint; sorting uses the published text so the order is stable.)
|
||||
*/
|
||||
export function snippetSortSize(snippet: Snippet): number {
|
||||
return snippet.spec.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two snippets by a field, ascending. Name is case-insensitive
|
||||
* (locale-aware); timestamps compare lexicographically (ISO-8601 sorts
|
||||
* chronologically as text); size compares numerically. Ties break by `id` so the
|
||||
* order is **stable** and deterministic across renders.
|
||||
*/
|
||||
function compareAsc(a: Snippet, b: Snippet, by: SortBy): number {
|
||||
let primary = 0;
|
||||
switch (by) {
|
||||
case 'name':
|
||||
primary = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
break;
|
||||
case 'created':
|
||||
primary = a.created.localeCompare(b.created);
|
||||
break;
|
||||
case 'modified':
|
||||
primary = a.modified.localeCompare(b.modified);
|
||||
break;
|
||||
case 'size':
|
||||
primary = snippetSortSize(a) - snippetSortSize(b);
|
||||
break;
|
||||
}
|
||||
// Stable tiebreak so equal-keyed rows keep a fixed, deterministic order.
|
||||
return primary !== 0 ? primary : a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new array of `snippets` ordered by the given sort state. Pure — never
|
||||
* mutates the input (sorts a copy).
|
||||
*/
|
||||
export function sortSnippets(
|
||||
snippets: readonly Snippet[],
|
||||
{ sortBy, sortOrder }: SortState,
|
||||
): Snippet[] {
|
||||
const dir = sortOrder === 'asc' ? 1 : -1;
|
||||
return [...snippets].sort((a, b) => dir * compareAsc(a, b, sortBy));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a snippet matches a search query (spec §02 → Search). Case-insensitive
|
||||
* substring across the snippet **name**, **comment**, and **spec content** (the
|
||||
* working draft text). An empty/whitespace query matches everything (search
|
||||
* affects visibility only — it never excludes when nothing was asked for).
|
||||
*/
|
||||
export function snippetMatchesQuery(snippet: Snippet, query: string): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q === '') return true;
|
||||
return (
|
||||
snippet.name.toLowerCase().includes(q) ||
|
||||
snippet.comment.toLowerCase().includes(q) ||
|
||||
snippet.draftSpec.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The library view: snippets filtered by `query` then ordered by `sort`. This is
|
||||
* the single function the component memoizes over primitive store values.
|
||||
*/
|
||||
export function filterAndSortSnippets(
|
||||
snippets: readonly Snippet[],
|
||||
query: string,
|
||||
sort: SortState,
|
||||
): Snippet[] {
|
||||
const filtered =
|
||||
query.trim() === '' ? [...snippets] : snippets.filter((s) => snippetMatchesQuery(s, query));
|
||||
return sortSnippets(filtered, sort);
|
||||
}
|
||||
Reference in New Issue
Block a user