Files
astrolabe/src/core/snippet.test.ts
T

160 lines
5.4 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import {
CURRENT_SNIPPET_VERSION,
createSnippet,
duplicateSnippet,
formatSnippetSize,
generateSnippetName,
hasUnpublishedChanges,
SAMPLE_SPEC,
sampleSpecText,
snippetSizeBytes,
} 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('duplicateSnippet', () => {
const source = {
...createSnippet({
id: 'src',
name: 'My Chart',
spec: '{"published":1}',
now: new Date('2026-01-01T00:00:00Z'),
}),
draftSpec: '{"draft":1}',
comment: 'a note',
tags: ['imported'],
datasetRefs: ['Sales'],
meta: { createdWith: 'chart-builder' },
};
test('suffixes the name with "(copy)" and takes a new identity + timestamps', () => {
const now = new Date('2026-03-04T05:06:07Z');
const copy = duplicateSnippet(source, { id: 'copy', now });
expect(copy.id).toBe('copy');
expect(copy.id).not.toBe(source.id);
expect(copy.name).toBe('My Chart (copy)');
expect(copy.version).toBe(CURRENT_SNIPPET_VERSION);
expect(copy.created).toBe(now.toISOString());
expect(copy.modified).toBe(now.toISOString());
});
test('carries over both spec versions, comment, tags, refs, and meta', () => {
const copy = duplicateSnippet(source);
expect(copy.spec).toBe('{"published":1}');
expect(copy.draftSpec).toBe('{"draft":1}');
expect(copy.comment).toBe('a note');
expect(copy.tags).toEqual(['imported']);
expect(copy.datasetRefs).toEqual(['Sales']);
expect(copy.meta).toEqual({ createdWith: 'chart-builder' });
});
test('clones mutable members so the copy shares no references with its source', () => {
const copy = duplicateSnippet(source);
expect(copy.tags).not.toBe(source.tags);
expect(copy.datasetRefs).not.toBe(source.datasetRefs);
expect(copy.meta).not.toBe(source.meta);
});
test('generates a unique id by default', () => {
expect(duplicateSnippet(source).id).not.toBe(duplicateSnippet(source).id);
});
});
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);
});
});
describe('snippetSizeBytes', () => {
test('measures the draft as UTF-8 byte length, not character count', () => {
const s = createSnippet({ spec: 'abc' });
expect(snippetSizeBytes(s)).toBe(3);
// A 3-character string of multibyte glyphs is more than 3 bytes.
expect(snippetSizeBytes({ ...s, draftSpec: '€€€' })).toBe(9);
});
test('reflects the working draft, not the published spec', () => {
const s = createSnippet({ spec: 'ab' });
expect(snippetSizeBytes({ ...s, draftSpec: 'abcdef' })).toBe(6);
});
});
describe('formatSnippetSize', () => {
test('omits the size under ~1 KB (spec §02 clutter rule)', () => {
expect(formatSnippetSize(0)).toBeNull();
expect(formatSnippetSize(512)).toBeNull();
expect(formatSnippetSize(1023)).toBeNull();
});
test('shows whole KB at and above the threshold', () => {
expect(formatSnippetSize(1024)).toBe('1 KB');
expect(formatSnippetSize(2048)).toBe('2 KB');
expect(formatSnippetSize(1536)).toBe('2 KB'); // rounds to nearest KB
});
test('rolls over to MB for large payloads', () => {
expect(formatSnippetSize(1024 * 1024)).toBe('1 MB');
expect(formatSnippetSize(3 * 1024 * 1024)).toBe('3 MB');
});
});