Implement M1 authoring loop: library, editor, live preview, persistence

M1 MVP from docs/IMPLEMENTATION-PLAN.md, with the /alignment pass applied.

- core: Snippet model + factory; prepareSpecForRender (copy-not-mutate); per-theme Vega chart config
- state/orchestration: SnippetStore with debounced auto-save; IndexedDB adapter + read-time migration; startup hydration + write-through persistence
- ui: SnippetLibrary, SpecEditor (Monaco edcore.main — full editor features, JSON-only languages), LivePreview
- build: Monaco/Vega manual chunks; raised PWA precache ceiling
- alignment: flush a valid draft on snippet switch (+regression tests); TODO breadcrumbs for the preview render race and the window.confirm delete
- housekeeping: gitignore .claude/projects/
This commit is contained in:
2026-06-05 00:16:24 +03:00
parent 056644450c
commit ca54bb66b1
34 changed files with 1557 additions and 74 deletions
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, test } from 'vitest';
import {
CURRENT_SNIPPET_VERSION,
createSnippet,
generateSnippetName,
hasUnpublishedChanges,
SAMPLE_SPEC,
sampleSpecText,
} from './snippet';
describe('createSnippet', () => {
test('stamps current version, equal timestamps, and the sample template', () => {
const now = new Date('2026-06-04T14:30:07.000Z');
const s = createSnippet({ now, id: 'fixed-id' });
expect(s.id).toBe('fixed-id');
expect(s.version).toBe(CURRENT_SNIPPET_VERSION);
expect(s.created).toBe(now.toISOString());
expect(s.modified).toBe(s.created);
expect(s.spec).toBe(sampleSpecText());
// draft starts identical to published — nothing to publish yet.
expect(s.draftSpec).toBe(s.spec);
expect(s.comment).toBe('');
expect(s.tags).toEqual([]);
expect(s.datasetRefs).toEqual([]);
expect(s.meta).toEqual({});
});
test('generates a unique id by default', () => {
const a = createSnippet();
const b = createSnippet();
expect(a.id).not.toBe(b.id);
});
test('accepts name and spec overrides', () => {
const s = createSnippet({ name: 'Custom', spec: '{"mark":"point"}' });
expect(s.name).toBe('Custom');
expect(s.spec).toBe('{"mark":"point"}');
expect(s.draftSpec).toBe('{"mark":"point"}');
});
});
describe('the sample template', () => {
test('is valid JSON that round-trips', () => {
expect(JSON.parse(sampleSpecText())).toEqual(SAMPLE_SPEC);
});
test('is a bar chart with inline data', () => {
expect(SAMPLE_SPEC.mark).toBe('bar');
expect(SAMPLE_SPEC.data.values.length).toBeGreaterThan(0);
});
});
describe('generateSnippetName', () => {
test('formats as "Snippet YYYY-MM-DD HH:MM:SS" with zero-padding', () => {
// Construct via local-time parts so the test is timezone-independent.
const now = new Date(2026, 0, 5, 9, 7, 3); // 2026-01-05 09:07:03 local
expect(generateSnippetName(now)).toBe('Snippet 2026-01-05 09:07:03');
});
test('differs second-to-second so quick successive creates stay distinct', () => {
const a = generateSnippetName(new Date(2026, 5, 4, 14, 30, 7));
const b = generateSnippetName(new Date(2026, 5, 4, 14, 30, 8));
expect(a).not.toBe(b);
});
});
describe('hasUnpublishedChanges', () => {
test('false when draft matches published, true once draft diverges', () => {
const s = createSnippet({ now: new Date('2026-06-04T00:00:00Z') });
expect(hasUnpublishedChanges(s)).toBe(false);
expect(hasUnpublishedChanges({ ...s, draftSpec: s.spec + ' ' })).toBe(true);
});
});