mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add URL hash view-state routing with Back/Forward and restore (M6, §01E)
The hash becomes the single serialized view (snippet / Datasets list / a dataset / new-dataset form / Chart Builder). url-hash is the sole reader/writer of location+history (total, never-throwing parse; hash-only canonical URLs). UrlStateSync grows from the modal↔URL seam into the full router: state→hash is derived from the stores (so in-modal dataset sub-views reach the hash), hash→state restores on load and on Back/Forward, validating ids and cleaning stale links. startRouting() runs after store hydrate.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
parseHash,
|
||||
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 },
|
||||
];
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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('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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user