Files
astrolabe/src/app/stores/DatasetStore.test.ts
T

363 lines
13 KiB
TypeScript

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<DatasetForm>) => {
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<DatasetForm>) => {
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);
});
});