Files
astrolabe/src/app/infrastructure/url-hash.test.ts
T

150 lines
5.7 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import {
parseHash,
parseExampleHash,
serializeHash,
readView,
replaceView,
pushView,
type ViewState,
} from './url-hash';
/** One value per `ViewState` variant — the round-trip and serialize coverage set. */
const ALL_VIEWS: ViewState[] = [
{ kind: 'snippets' },
{ kind: 'snippet', snippetId: 'abc123' },
{ kind: 'datasets' },
{ kind: 'dataset', datasetId: 7 },
{ kind: 'dataset-new' },
{ kind: 'dataset-build', datasetId: 42 },
{ kind: 'build' },
];
describe('serializeHash', () => {
it('produces the spec grammar form for each variant', () => {
expect(serializeHash({ kind: 'snippets' })).toBe('');
expect(serializeHash({ kind: 'snippet', snippetId: 'abc123' })).toBe('#snippet-abc123');
expect(serializeHash({ kind: 'datasets' })).toBe('#datasets');
expect(serializeHash({ kind: 'dataset', datasetId: 7 })).toBe('#datasets/dataset-7');
expect(serializeHash({ kind: 'dataset-new' })).toBe('#datasets/new');
expect(serializeHash({ kind: 'dataset-build', datasetId: 42 })).toBe(
'#datasets/dataset-42/build',
);
expect(serializeHash({ kind: 'build' })).toBe('#build');
});
});
describe('parseHash', () => {
it('parses each grammar row to the right ViewState', () => {
expect(parseHash('')).toEqual({ kind: 'snippets' });
expect(parseHash('#snippet-abc123')).toEqual({ kind: 'snippet', snippetId: 'abc123' });
expect(parseHash('#datasets')).toEqual({ kind: 'datasets' });
expect(parseHash('#datasets/dataset-7')).toEqual({ kind: 'dataset', datasetId: 7 });
expect(parseHash('#datasets/new')).toEqual({ kind: 'dataset-new' });
expect(parseHash('#datasets/dataset-42/build')).toEqual({
kind: 'dataset-build',
datasetId: 42,
});
expect(parseHash('#build')).toEqual({ kind: 'build' });
});
it('tolerates a missing leading "#"', () => {
expect(parseHash('datasets')).toEqual({ kind: 'datasets' });
expect(parseHash('snippet-x')).toEqual({ kind: 'snippet', snippetId: 'x' });
});
it('treats a snippet id as an opaque string (slashes, dashes, etc.)', () => {
expect(parseHash('#snippet-a-b-c')).toEqual({ kind: 'snippet', snippetId: 'a-b-c' });
expect(parseHash('#snippet-2026-06-07')).toEqual({ kind: 'snippet', snippetId: '2026-06-07' });
});
it('falls back to the default snippets view on malformed / unknown hashes', () => {
const fallback: ViewState = { kind: 'snippets' };
expect(parseHash('#snippet-')).toEqual(fallback); // empty snippet id
expect(parseHash('#datasets/dataset-abc')).toEqual(fallback); // non-numeric dataset id
expect(parseHash('#datasets/dataset-')).toEqual(fallback); // empty dataset id
expect(parseHash('#datasets/dataset-7/extra')).toEqual(fallback); // unknown trailing segment
expect(parseHash('#datasets/bogus')).toEqual(fallback); // unknown datasets sub-route
expect(parseHash('#garbage')).toEqual(fallback); // unknown top-level
});
it('never throws for arbitrary input', () => {
for (const raw of ['#', '#/', '#//', '#datasets//', '#snippet', '###']) {
expect(() => parseHash(raw)).not.toThrow();
expect(parseHash(raw).kind).toBeDefined();
}
});
});
describe('parseExampleHash', () => {
it('extracts the example id from the one-shot action link', () => {
expect(parseExampleHash('#example-brush')).toBe('brush');
expect(parseExampleHash('example-bar')).toBe('bar'); // leading "#" optional
});
it('returns null for anything that is not an example link', () => {
expect(parseExampleHash('')).toBeNull();
expect(parseExampleHash('#example-')).toBeNull(); // empty id
expect(parseExampleHash('#snippet-abc')).toBeNull();
expect(parseExampleHash('#build')).toBeNull();
});
it('is not a ViewState: parseHash degrades an example link to the default view', () => {
expect(parseHash('#example-brush')).toEqual({ kind: 'snippets' });
});
});
describe('round-trip identity', () => {
it('parseHash(serializeHash(v)) deep-equals v for every variant', () => {
for (const v of ALL_VIEWS) {
expect(parseHash(serializeHash(v))).toEqual(v);
}
});
});
describe('readView / replaceView / pushView (window + history)', () => {
beforeEach(() => {
// Reset to a clean, hash-less location before each case.
window.history.replaceState({}, '', '/');
});
it('readView reflects the current location.hash', () => {
window.location.hash = '#datasets/dataset-7';
expect(readView()).toEqual({ kind: 'dataset', datasetId: 7 });
});
it('readView returns the default for an empty hash', () => {
expect(readView()).toEqual({ kind: 'snippets' });
});
it('replaceView writes the hash without growing history', () => {
const before = window.history.length;
replaceView({ kind: 'snippet', snippetId: 'xyz' });
expect(window.location.hash).toBe('#snippet-xyz');
expect(readView()).toEqual({ kind: 'snippet', snippetId: 'xyz' });
expect(window.history.length).toBe(before);
});
it('replaceView clears the hash for the default view', () => {
window.location.hash = '#datasets';
replaceView({ kind: 'snippets' });
expect(window.location.hash).toBe('');
expect(readView()).toEqual({ kind: 'snippets' });
});
it('pushView writes the hash and adds a history entry', () => {
const before = window.history.length;
pushView({ kind: 'dataset-build', datasetId: 42 });
expect(window.location.hash).toBe('#datasets/dataset-42/build');
expect(readView()).toEqual({ kind: 'dataset-build', datasetId: 42 });
expect(window.history.length).toBe(before + 1);
});
it('round-trips through the real history API for every variant', () => {
for (const v of ALL_VIEWS) {
replaceView(v);
expect(readView()).toEqual(v);
}
});
});