import { beforeEach, describe, expect, test } from 'vitest'; import { createDataset } from '@core/dataset'; import { createSnippet } from '@core/snippet'; import { selectCanSave, selectSelectedDataset, useDatasetStore, type DatasetForm, } from './DatasetStore'; import { useSnippetStore } from './SnippetStore'; const store = () => useDatasetStore.getState(); const T = new Date('2026-06-01T00:00:00Z'); beforeEach(() => { store().reset(); useSnippetStore.getState().reset(); }); describe('save — create', () => { test('valid inline JSON is added, profiled, and selected', () => { store().startCreate(); store().updateForm({ name: 'Sales', input: '[{"a":1,"b":2},{"a":3,"b":4}]' }); expect(store().save(T)).toBe(true); const ds = selectSelectedDataset(store()); expect(ds?.name).toBe('Sales'); expect(ds?.format).toBe('json'); expect(ds?.rowCount).toBe(2); expect(ds?.columns).toEqual(['a', 'b']); expect(store().view).toBe('detail'); }); test('CSV input is stored as raw text and profiled', () => { store().startCreate(); store().updateForm({ name: 'Regions', input: 'city,pop\nA,10\nB,20' }); expect(store().save(T)).toBe(true); const ds = selectSelectedDataset(store()); expect(ds?.format).toBe('csv'); expect(ds?.data).toBe('city,pop\nA,10\nB,20'); expect(ds?.rowCount).toBe(2); }); test('save() does not commit a URL create — it must go through commitUrlSnapshot', () => { store().startCreate(); store().updateForm({ name: 'Remote', source: 'url', input: 'https://example.com/data.csv' }); // The component fetches first; save() never touches the network, so a URL create // through save() is a no-op rather than an unfetched record. expect(store().save(T)).toBe(false); expect(store().datasets).toHaveLength(0); }); }); describe('save — rename propagation (docs/architecture/07 §6)', () => { test('renaming via the edit form rewrites every referencing snippet', () => { store().startCreate(); store().updateForm({ name: 'Sales', input: '[{"a":1}]' }); expect(store().save(T)).toBe(true); // A published snippet referencing the dataset by name. useSnippetStore.getState().hydrate( [ createSnippet({ id: 'a', spec: JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' }), now: T, }), ], 'a', ); useSnippetStore.getState().publish(T); expect(useSnippetStore.getState().snippets[0].datasetRefs).toEqual(['Sales']); store().startEdit(); store().updateForm({ name: 'Revenue' }); expect(store().save(new Date('2026-07-01T00:00:00Z'))).toBe(true); expect(store().datasets[0].name).toBe('Revenue'); const s = useSnippetStore.getState().snippets[0]; expect(s.datasetRefs).toEqual(['Revenue']); expect(s.spec).toContain('"Revenue"'); expect(s.spec).not.toContain('"Sales"'); }); }); describe('commitUrlSnapshot — create from a fetched body', () => { test('snapshots the body, infers format from content, and profiles it', () => { store().startCreate(); store().updateForm({ name: 'Remote', source: 'url', input: 'https://example.com/data.csv' }); expect(store().commitUrlSnapshot({ text: 'a,b\n1,2\n3,4' }, T)).toBe(true); const ds = selectSelectedDataset(store()); expect(ds?.source).toBe('url'); expect(ds?.url).toBe('https://example.com/data.csv'); expect(ds?.format).toBe('csv'); expect(ds?.data).toBe('a,b\n1,2\n3,4'); expect(ds?.rowCount).toBe(2); expect(ds?.columns).toEqual(['a', 'b']); expect(ds?.fetchedAt).toBe(T.toISOString()); expect(store().view).toBe('detail'); }); test('a duplicate name is rejected even with a successful fetch', () => { store().add( createDataset({ name: 'Dupe', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }), ); store().startCreate(); store().updateForm({ name: 'dupe', source: 'url', input: 'https://x/y.csv' }); expect(store().commitUrlSnapshot({ text: 'a\n1' }, T)).toBe(false); expect(store().formError).toMatch(/already exists/i); }); }); describe('URL dataset edits & refresh', () => { const seedUrl = () => { store().startCreate(); store().updateForm({ name: 'Remote', source: 'url', input: 'https://x/y.csv' }); store().commitUrlSnapshot({ text: 'a,b\n1,2' }, T); return store().selectedId!; }; test('a metadata-only edit (same URL) updates name without re-fetching', () => { const id = seedUrl(); store().startEdit(); store().updateForm({ name: 'Renamed' }); // URL field stays https://x/y.csv const later = new Date('2026-07-01T00:00:00Z'); expect(store().save(later)).toBe(true); const ds = store().datasets.find((d) => d.id === id)!; expect(ds.name).toBe('Renamed'); expect(ds.data).toBe('a,b\n1,2'); // snapshot untouched expect(ds.fetchedAt).toBe(T.toISOString()); // not re-fetched expect(ds.modified).toBe(later.toISOString()); }); test('refreshDataset re-snapshots, re-profiles, and advances fetchedAt', () => { const id = seedUrl(); const later = new Date('2026-07-01T00:00:00Z'); expect(store().refreshDataset(id, { text: 'a,b\n1,2\n3,4\n5,6' }, later)).toBe(true); const ds = store().datasets.find((d) => d.id === id)!; expect(ds.rowCount).toBe(3); expect(ds.fetchedAt).toBe(later.toISOString()); expect(ds.modified).toBe(later.toISOString()); expect(ds.name).toBe('Remote'); // preserved }); test('a URL→inline conversion removes the url/fetchedAt keys (not left undefined)', () => { const id = seedUrl(); store().startEdit(); store().updateForm({ source: 'inline', input: '[{"x":1},{"x":2}]' }); const later = new Date('2026-07-01T00:00:00Z'); expect(store().save(later)).toBe(true); const ds = store().datasets.find((d) => d.id === id)!; expect(ds.source).toBe('inline'); expect(ds.rowCount).toBe(2); // The remote origin is shed entirely — the keys are gone, not present-but-undefined, // so an inline record carries no stale url/fetchedAt. expect('url' in ds).toBe(false); expect('fetchedAt' in ds).toBe(false); }); test('refreshDataset is a no-op for an inline dataset', () => { store().add( createDataset({ name: 'Inline', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }), ); const id = store().selectedId!; expect(store().refreshDataset(id, { text: 'a\n1' }, T)).toBe(false); }); }); describe('save — validation', () => { const submit = (patch: Partial) => { store().startCreate(); store().updateForm(patch); return store().save(T); }; test('blank name is rejected', () => { expect(submit({ name: ' ', input: '[{"a":1}]' })).toBe(false); expect(store().formError).toMatch(/name/i); expect(store().datasets).toHaveLength(0); }); test('duplicate name (case-insensitive) is rejected', () => { store().add( createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }), ); expect(submit({ name: 'sales', input: '[{"a":1}]' })).toBe(false); expect(store().formError).toMatch(/already exists/i); }); test('empty and unrecognized inline data are rejected', () => { expect(submit({ name: 'Empty', input: ' ' })).toBe(false); expect(submit({ name: 'Junk', input: 'this is not data' })).toBe(false); expect(store().formError).toMatch(/valid JSON/i); }); test('a non-http URL is rejected', () => { expect(submit({ name: 'Bad', source: 'url', input: 'ftp://x/y.csv' })).toBe(false); expect(store().formError).toMatch(/url/i); }); }); describe('selectCanSave — drives the Save button disabled state (§05)', () => { const canSaveWith = (patch: Partial) => { store().startCreate(); store().updateForm(patch); return selectCanSave(useDatasetStore.getState()); }; test('false until a name and valid data are present', () => { store().startCreate(); expect(selectCanSave(useDatasetStore.getState())).toBe(false); // empty form expect(canSaveWith({ name: 'Sales' })).toBe(false); // no data expect(canSaveWith({ name: ' ', input: '[{"a":1}]' })).toBe(false); // blank name expect(canSaveWith({ name: 'Sales', input: 'not data' })).toBe(false); // unrecognized }); test('true for valid inline data and valid URLs', () => { expect(canSaveWith({ name: 'Sales', input: '[{"a":1,"b":2}]' })).toBe(true); expect(canSaveWith({ name: 'Regions', input: 'city,pop\nA,10' })).toBe(true); expect(canSaveWith({ name: 'Remote', source: 'url', input: 'https://x/y.csv' })).toBe(true); }); test('false for a non-http URL', () => { expect(canSaveWith({ name: 'Bad', source: 'url', input: 'ftp://x/y.csv' })).toBe(false); }); test('false on a duplicate name, true once the editing dataset keeps its own name', () => { store().add( createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }), ); const id = store().datasets[0].id; // Creating another "Sales" is blocked. expect(canSaveWith({ name: 'Sales', input: '[{"a":1}]' })).toBe(false); // Editing the existing one may keep its own name (excludeId). store().select(id); store().startEdit(); expect(selectCanSave(useDatasetStore.getState())).toBe(true); }); }); describe('save — edit', () => { test('updating inline data re-profiles and advances modified', () => { store().add( createDataset({ name: 'D', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }), ); const id = store().selectedId!; store().startEdit(); store().updateForm({ input: '[{"a":1,"b":2},{"a":3,"b":4}]' }); const later = new Date('2026-07-01T00:00:00Z'); expect(store().save(later)).toBe(true); const ds = store().datasets.find((d) => d.id === id)!; expect(ds.columnCount).toBe(2); expect(ds.modified).toBe(later.toISOString()); }); test('renaming a referenced dataset propagates into referencing snippets', () => { store().add( createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }), ); const snippet = createSnippet({ id: 's1', spec: JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' }), now: T, }); useSnippetStore.getState().hydrate([snippet], 's1'); useSnippetStore.getState().publish(T); // seed datasetRefs = ['Sales'] store().startEdit(); store().updateForm({ name: 'Revenue' }); expect(store().save(new Date('2026-08-01T00:00:00Z'))).toBe(true); expect(store().datasets[0].name).toBe('Revenue'); const s = useSnippetStore.getState().snippets.find((x) => x.id === 's1')!; expect(s.datasetRefs).toEqual(['Revenue']); expect(s.spec).toContain('"Revenue"'); }); }); describe('remove & view transitions', () => { test('removing the selected dataset clears the selection and returns to the list', () => { store().add( createDataset({ name: 'D', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }), ); const id = store().selectedId!; store().remove(id); expect(store().selectedId).toBeNull(); expect(store().view).toBe('list'); }); test('cancelForm returns to detail when a dataset is selected', () => { store().add( createDataset({ name: 'D', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }), ); store().startEdit(); expect(store().view).toBe('edit'); store().cancelForm(); expect(store().view).toBe('detail'); }); }); describe('id assignment — the store is the collision-free id authority', () => { test('add reassigns a fresh id even when two creates share a Date.now() id', () => { // Simulate the tight-loop hazard: two datasets minted with the SAME injected id // (what Date.now() yields within one millisecond). add must still give them // distinct, monotonically increasing ids so they cannot collide in IndexedDB. store().add( createDataset({ name: 'A', data: [{ a: 1 }], format: 'json', source: 'inline', now: T, id: 42, }), ); store().add( createDataset({ name: 'B', data: [{ a: 1 }], format: 'json', source: 'inline', now: T, id: 42, }), ); const ids = store().datasets.map((d) => d.id); expect(new Set(ids).size).toBe(2); // selectedId tracks the reassigned id of the most recent add, not the input 42. expect(store().selectedId).toBe(store().datasets[0].id); }); test('addDatasets gives a batch distinct ids past the existing maximum', () => { store().add( createDataset({ name: 'Seed', data: [{ a: 1 }], format: 'json', source: 'inline', now: T }), ); const seedId = store().datasets[0].id; store().addDatasets([ createDataset({ name: 'X', data: [{ a: 1 }], format: 'json', source: 'inline', now: T, id: 42, }), createDataset({ name: 'Y', data: [{ a: 1 }], format: 'json', source: 'inline', now: T, id: 42, }), ]); const ids = store().datasets.map((d) => d.id); expect(new Set(ids).size).toBe(3); expect(Math.min(...ids.filter((i) => i !== seedId))).toBeGreaterThan(seedId); }); });