floor-planner/tests/url-state.test.ts
2026-09-11 17:57:03 +01:00

583 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import LZString from 'lz-string';
import { describe, expect, it } from 'vitest';
import { defaultState, fireplace, rectangle } from '../src/lib/defaults';
import { decodeState, encodeState, URL_LIMITS } from '../src/lib/url-state';
function rawLink(value: unknown): string {
return `#plan=${LZString.compressToEncodedURIComponent(JSON.stringify(value))}`;
}
function expectInvalid(hash: string, message?: RegExp) {
const result = decodeState(hash);
expect(result.state).toEqual(defaultState());
expect(result.error).toMatch(message ?? /Could not load this plan/);
}
describe('URL state', () => {
it('starts with independent defaults when no link is provided', () => {
for (const hash of ['', '#', '?']) {
const first = decodeState(hash);
expect(first).toEqual({ state: defaultState(), error: null });
first.state.project.rooms[0].name = 'Edited';
expect(decodeState(hash).state.project.rooms[0].name).toBe('Living room');
}
});
it('round-trips all default inputs exactly and produces the same link every time', () => {
const state = defaultState();
const hash = encodeState(state);
expect(hash).toMatch(/^\?version=1&name=/);
const params = new URLSearchParams(hash);
expect(params.get('flooring.length')).toBe('1200');
expect(params.get('skirting.length')).toBe('2400');
expect(params.has('skirting.stock')).toBe(false);
expect(params.get('room.1.wall.1.length')).toBe('4800');
expect(params.get('room.1.wall.4.door.1.width')).toBe('826');
expect(hash).not.toContain('#plan=');
expect(decodeState(hash)).toEqual({ state, error: null });
expect(encodeState(decodeState(hash).state)).toBe(hash);
expect(encodeState(structuredClone(state))).toBe(hash);
});
it('round-trips Unicode, multiple rooms, doors, materials and view selections', () => {
const state = defaultState();
state.project.name = 'Zoë’s 家 🏡 – mõõtmised';
state.project.rooms.push(fireplace('room-2', 'Slaapkamer 北'));
state.project.rooms[1].direction = 90;
state.project.rooms[0].walls[0].doors = [{ id: 'wide-door', offset: 123.75, width: 1500 }];
state.project.flooring.pattern = 'random';
state.project.skirting.stockLength = 3600;
state.view = { ...state.view, roomId: 'room-2', selectedPiece: 'A12' };
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
});
it('loads legacy step, layer and zoom links into the same workspace', () => {
const state = defaultState();
const old = {
...state,
view: {
roomId: state.view.roomId,
selectedPiece: 'A4',
step: 'plan',
layer: 'skirting',
zoom: 2,
},
};
const decoded = decodeState(rawLink(old));
expect(decoded.error).toBeNull();
expect(decoded.state.project).toEqual(state.project);
expect(decoded.state.view).toEqual({ ...state.view, selectedPiece: 'A4' });
expect(Object.keys(decoded.state.view).sort()).toEqual([
'collapsedSections',
'roomId',
'search',
'selectedPiece',
]);
});
it('canonicalizes property order and discards derived or unknown fields', () => {
const state = defaultState();
const reordered = {
view: { ...state.view, derived: 6 },
project: { ...state.project, boardCount: 500 },
cache: [],
};
expect(encodeState(reordered)).toBe(encodeState(state));
expect(decodeState(rawLink(reordered))).toEqual({ state, error: null });
});
it('preserves finite but impossible measurements for domain-level feedback', () => {
const state = defaultState();
state.project.rooms[0].walls[0].length = -1;
state.project.rooms[0].walls[0].turn = 999;
state.project.flooring.width = 0;
state.project.flooring.minOffset = 1000;
state.project.flooring.maxOffset = 100;
state.project.skirting.thickness = -20;
state.project.tolerance = -1;
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
});
it('allows an unfinished room and invalid board length without inventing inputs', () => {
const state = defaultState();
state.project.rooms[0].walls = [];
state.project.skirting.stockLength = 0;
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
});
it('falls back to the first room for a stale selected room', () => {
const state = defaultState();
state.view.roomId = 'deleted-room';
const result = decodeState(rawLink(state));
expect(result.error).toBeNull();
expect(result.state.view.roomId).toBe(state.project.rooms[0].id);
});
it('accepts URL-escaped legacy compression characters', () => {
const hash = rawLink(defaultState());
expect(decodeState(`#plan=${encodeURIComponent(hash.slice(6))}`).state).toEqual(defaultState());
});
it.each(['#other=value', '#plan=', '#plan=garbage', '#plan=%', '#plan=%20', '#plan=abc&more=1'])(
'rejects malformed link %s with an actionable error',
(hash) => expectInvalid(hash),
);
it('rejects truncated and appended legacy payloads even when the decoder ignores trailing data', () => {
const hash = rawLink(defaultState());
expectInvalid(hash.slice(0, -4));
expectInvalid(`${hash}AAAA`);
});
it('rejects a valid compression payload containing invalid JSON', () => {
expectInvalid(
`#plan=${LZString.compressToEncodedURIComponent('{broken')}`,
/project data is damaged/,
);
});
it('rejects unsupported schema versions', () => {
const state = defaultState();
expectInvalid(
rawLink({ ...state, project: { ...state.project, version: 2 } }),
/version is unsupported/,
);
});
it.each([null, [], {}, { project: null }, { project: { version: 1 } }])(
'rejects missing project data',
(value) => {
expectInvalid(rawLink(value));
},
);
it.each([null, '1200', true, {}, []])('rejects incorrectly typed measurements', (length) => {
const state = defaultState();
expectInvalid(
rawLink({
...state,
project: { ...state.project, flooring: { ...state.project.flooring, length } },
}),
/finite number/,
);
});
it('rejects nonfinite numbers both when reading and writing', () => {
const state = defaultState();
const json = JSON.stringify(state).replace('"length":4800', '"length":1e999');
expectInvalid(`#plan=${LZString.compressToEncodedURIComponent(json)}`, /finite number/);
for (const invalid of [NaN, Infinity, -Infinity]) {
state.project.flooring.length = invalid;
expect(() => encodeState(state)).toThrow(/finite number/);
}
});
it.each([
['selectedPiece', false],
['selectedPiece', {}],
['selectedPiece', 12],
['roomId', null],
])('rejects invalid view field %s', (field, value) => {
const state = defaultState();
expectInvalid(rawLink({ ...state, view: { ...state.view, [field]: value } }));
});
it('rejects invalid flooring enums', () => {
const state = defaultState();
expectInvalid(
rawLink({
...state,
project: { ...state.project, flooring: { ...state.project.flooring, pattern: 'diagonal' } },
}),
);
expectInvalid(
rawLink({
...state,
project: { ...state.project, rooms: [{ ...state.project.rooms[0], direction: 45 }] },
}),
);
});
it('rejects too many rooms, walls, doors or legacy stock lengths', () => {
const rooms = defaultState();
rooms.project.rooms = Array.from({ length: URL_LIMITS.rooms + 1 }, (_, i) =>
rectangle(`room-${i}`),
);
expectInvalid(rawLink(rooms), /at most 20/);
const walls = defaultState();
walls.project.rooms[0].walls = Array.from({ length: URL_LIMITS.walls + 1 }, (_, i) => ({
id: `wall-${i}`,
length: 1,
turn: 90,
doors: [],
}));
expectInvalid(rawLink(walls), /at most 100/);
const doors = defaultState();
doors.project.rooms[0].walls[0].doors = Array.from(
{ length: URL_LIMITS.doors + 1 },
(_, i) => ({ id: `door-${i}`, offset: i * 10, width: 5 }),
);
expectInvalid(rawLink(doors), /at most 30/);
const stock = defaultState();
expectInvalid(
rawLink({
...stock,
project: {
...stock.project,
skirting: {
...stock.project.skirting,
stockLength: undefined,
stockLengths: Array(URL_LIMITS.legacyStockLengths + 1).fill(2400),
},
},
}),
/at most 30/,
);
});
it('rejects no rooms, duplicate identifiers and oversized strings', () => {
const empty = defaultState();
empty.project.rooms = [];
expectInvalid(rawLink(empty), /at least one room/);
const duplicate = defaultState();
duplicate.project.rooms.push(rectangle());
expectInvalid(rawLink(duplicate), /duplicate identifiers/);
const duplicateWall = defaultState();
duplicateWall.project.rooms[0].walls.push(duplicateWall.project.rooms[0].walls[0]);
expectInvalid(rawLink(duplicateWall), /duplicate identifiers/);
const largeName = defaultState();
largeName.project.name = 'a'.repeat(URL_LIMITS.nameLength + 1);
expectInvalid(rawLink(largeName), /at most 200 characters/);
});
it('uses individually editable fields and canonicalizes query parameter order', () => {
const state = defaultState();
const params = new URLSearchParams(encodeState(state));
params.set('flooring.length', '1400');
params.set('flooring.maxOffset', '700');
params.set('room.1.wall.1.length', '5123.5');
params.set('room.1.wall.4.door.1.width', '900');
params.set('skirting.length', '3600');
params.set('selectedCut', 'F12');
const result = decodeState(`?${params}`);
expect(result.error).toBeNull();
expect(result.state.project.flooring.length).toBe(1400);
expect(result.state.project.rooms[0].walls[0].length).toBe(5123.5);
expect(result.state.project.rooms[0].walls[3].doors[0].width).toBe(900);
expect(result.state.project.skirting.stockLength).toBe(3600);
expect(result.state.view.selectedPiece).toBe('F12');
const reversed = new URLSearchParams([...params].reverse());
expect(encodeState(decodeState(`?${reversed}`).state)).toBe(encodeState(result.state));
});
it('accepts a small hand-written query and uses defaults for omitted material fields', () => {
const result = decodeState('?flooring.length=1400&flooring.pattern=random&selectedCut=F2');
const state = defaultState();
state.project.flooring.length = 1400;
state.project.flooring.pattern = 'random';
state.view.selectedPiece = 'F2';
expect(result).toEqual({ state, error: null });
});
it('can describe an unfinished room without storing array counts or generated plans', () => {
const result = decodeState(
'?room.1.name=Office&room.1.wall.1.length=4000&room.1.wall.1.turn=-90',
);
expect(result.error).toBeNull();
expect(result.state.project.rooms).toEqual([
{
id: 'room-1',
name: 'Office',
direction: 0,
walls: [{ id: 'room-1-w1', length: 4000, turn: -90, doors: [] }],
},
]);
const encoded = encodeState(result.state);
expect(encoded).not.toMatch(/count|polygon|stocks|pieces|JSON|plan=/);
expect(new URLSearchParams(encoded).has('room.1.wall.2.length')).toBe(false);
});
it('preserves a zero board length and empty room while omitting an unselected cut', () => {
const state = defaultState();
state.project.skirting.stockLength = 0;
state.project.rooms[0].walls = [];
const query = encodeState(state);
expect(query).toContain('skirting.length=0');
expect(query).not.toContain('selectedCut=');
expect(decodeState(query)).toEqual({ state, error: null });
});
it.each([
'?flooring.length=NaN',
'?flooring.length=Infinity',
'?flooring.length=1e999',
'?flooring.length=',
'?flooring.length=true',
'?flooring.length=0x10',
'?flooring.pattern=diagonal',
'?room.1.direction=45',
'?version=2',
'?room.1.wall.1.turn=90',
'?room.1.wall.1.length=4000&room.1.wall.1.door.1.offset=300',
'?skirting.stock=&skirting.stock=2400',
'?name=%',
'?name=%C0%AF',
])('rejects an invalid editable query %s', (query) => expectInvalid(query));
it('rejects duplicate single-value fields and migrates repeated legacy stock lengths', () => {
expectInvalid('?flooring.length=1200&flooring.length=1400', /only once/);
expectInvalid('?selectedRoom=one&selectedRoom=two', /only once/);
expectInvalid('?room.1.name=Office&room.1.name=Kitchen', /only once/);
const result = decodeState('?skirting.stock=2400&skirting.stock=3600');
expect(result.error).toBeNull();
expect(result.state.project.skirting.stockLength).toBe(2400);
expectInvalid('?skirting.length=2400&skirting.length=3600', /only once/);
});
it.each([
{ lengths: [3600, 2400], chosen: 3600, completed: [] },
{ lengths: [2400, 3600], chosen: 2400, completed: [] },
{ lengths: [3600], chosen: 3600, completed: [] },
{ lengths: [3600, 3600], chosen: 3600, completed: [] },
{ lengths: [], chosen: 0, completed: [] },
])(
'migrates old stock sizes $lengths consistently in query and compressed links',
({ lengths, chosen, completed }) => {
const state = defaultState();
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
const old = {
...state,
project: {
...state.project,
skirting: { ...state.project.skirting, stockLength: undefined, stockLengths: lengths },
},
};
const query = new URLSearchParams();
for (const length of lengths.length ? lengths : [''])
query.append('skirting.stock', String(length));
for (const board of state.project.completedBoards) query.append('done', board);
state.project.skirting.stockLength = chosen;
state.project.completedBoards = completed;
for (const link of [rawLink(old), `?${query}`]) {
const result = decodeState(link);
expect(result).toEqual({ state, error: null });
const canonical = encodeState(result.state);
expect(canonical).toContain(`skirting.length=${chosen}`);
expect(canonical).not.toContain('skirting.stock');
expect(decodeState(canonical)).toEqual(result);
}
},
);
it.each(['?done=skirting:S4', '?skirting.stock=2400&done=skirting:S4'])(
'clears completion from earlier calculations in %s and preserves new progress after migration',
(link) => {
const migrated = decodeState(link);
expect(migrated.error).toBeNull();
expect(migrated.state.project.completedBoards).toEqual([]);
migrated.state.project.completedBoards = ['skirting:S4'];
expect(decodeState(encodeState(migrated.state))).toEqual(migrated);
},
);
it('prefers the current length field over legacy stock lists without clearing progress', () => {
const state = defaultState();
state.project.skirting.stockLength = 4800;
state.project.completedBoards = ['skirting:S6'];
const legacy = {
...state,
project: {
...state.project,
skirting: { ...state.project.skirting, stockLengths: [3600, 2400] },
},
};
expect(decodeState(rawLink(legacy))).toEqual({ state, error: null });
expect(
decodeState('?skirting.length=4800&skirting.stock=3600&skirting.stock=2400&done=skirting:S6'),
).toEqual({ state, error: null });
});
it.each([null, '2400', true, {}, [], NaN, Infinity])(
'rejects invalid skirting board length %j',
(stockLength) => {
const state = defaultState();
expectInvalid(
rawLink({
...state,
project: { ...state.project, skirting: { ...state.project.skirting, stockLength } },
}),
/finite number/,
);
},
);
it.each(['', 'NaN', 'Infinity', 'true', '0x10'])(
'rejects invalid editable skirting length %s',
(length) => {
expectInvalid(`?skirting.length=${length}`, /finite number/);
},
);
it('rejects sparse, zero, malformed and excessive room, wall and door indices', () => {
expectInvalid('?room.2.id=second', /consecutive/);
expectInvalid('?room.0.id=zero', /positive whole numbers/);
expectInvalid('?room.one.id=one', /positive whole numbers/);
expectInvalid('?room.21.id=excess', /at most 20/);
expectInvalid('?room.1.wall.101.length=4000', /at most 100/);
expectInvalid('?room.1.wall.1.length=4000&room.1.wall.1.door.31.width=500', /at most 30/);
expectInvalid(`?${Array(31).fill('skirting.stock=2400').join('&')}`, /at most 30/);
expectInvalid('?room.1.id=same&room.2.id=same', /duplicate identifiers/);
});
it('discards unrecognized query fields instead of storing derived state', () => {
const decoded = decodeState('?flooring.length=1400&boardCount=999&cache=old&room.8.cache=old');
expect(decoded.error).toBeNull();
const canonical = encodeState(decoded.state);
expect(canonical).toContain('flooring.length=1400');
expect(canonical).not.toMatch(/boardCount|cache/);
expect(decoded.state.project.rooms).toEqual(defaultState().project.rooms);
});
it('converts a legacy shared plan to readable fields without losing inputs or selections', () => {
const state = defaultState();
state.project.rooms.push(fireplace('second', 'Fireplace room'));
state.project.skirting.stockLength = 3600;
state.view = { ...state.view, roomId: 'second', selectedPiece: 'B2' };
const legacy = decodeState(rawLink(state));
expect(legacy.error).toBeNull();
const query = encodeState(legacy.state);
expect(query).toContain('room.2.wall.4.turn=-90');
expect(query).toContain('selectedRoom=second&selectedCut=B2');
expect(decodeState(query)).toEqual({ state, error: null });
});
it('stores progress and view preferences as optional readable fields', () => {
const state = defaultState();
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
state.view.search = 'F12';
state.view.collapsedSections = ['materials', 'measurements'];
const query = encodeState(state);
expect(query).toContain('done=floor:B4&done=skirting:S6');
expect(query).toContain('search=F12&collapsed=materials&collapsed=measurements');
expect(decodeState(query)).toEqual({ state, error: null });
const empty = new URLSearchParams(encodeState(defaultState()));
for (const key of ['done', 'search', 'collapsed']) expect(empty.has(key)).toBe(false);
});
it('canonicalizes completion and collapse sets without mutating their inputs', () => {
const state = defaultState();
state.project.completedBoards = ['skirting:S6', 'floor:B4'];
state.view.collapsedSections = ['measurements', 'cuts', 'materials'];
const before = structuredClone(state);
const query = encodeState(state);
const loaded = decodeState(query);
expect(loaded.error).toBeNull();
expect(loaded.state.project.completedBoards).toEqual(['floor:B4', 'skirting:S6']);
expect(loaded.state.view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']);
expect(state).toEqual(before);
state.project.completedBoards.reverse();
state.view.collapsedSections.reverse();
expect(encodeState(state)).toBe(query);
expect(encodeState(loaded.state)).toBe(query);
});
it('loads old query and fragment links with empty progress and view preferences', () => {
const state = defaultState();
const old = {
project: { ...state.project, completedBoards: undefined },
view: { roomId: state.view.roomId, selectedPiece: null },
};
expect(decodeState(rawLink(old))).toEqual({ state, error: null });
expect(decodeState('?flooring.length=1200').state).toEqual(state);
const first = defaultState();
first.project.completedBoards.push('floor:B4');
first.view.collapsedSections.push('materials');
expect(defaultState().project.completedBoards).toEqual([]);
expect(defaultState().view.collapsedSections).toEqual([]);
});
it('preserves Unicode and spaces in the search input independently of selected cuts', () => {
const state = defaultState();
state.view.search = ' Café 北 F12 / A2 ';
state.view.selectedPiece = 'F12';
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
expectInvalid('?search=F1&search=F2', /only once/);
});
it.each([
'',
'B4',
'floor:S4',
'skirting:B4',
'floor:B0',
'floor:B04',
'floor:B-1',
'floor:B1.5',
])('rejects invalid completed-board key %s', (key) => {
expectInvalid(`?done=${encodeURIComponent(key)}`, /floor:B1 or skirting:S1/);
});
it('rejects duplicate completion and collapse entries rather than counting them twice', () => {
expectInvalid('?done=floor:B4&done=floor:B4', /duplicate entries/);
expectInvalid('?collapsed=materials&collapsed=materials', /duplicate entries/);
const state = defaultState();
state.project.completedBoards = ['floor:B4', 'floor:B4'];
expect(() => encodeState(state)).toThrow(/duplicate entries/);
const distinct = decodeState('?skirting.length=2400&done=floor:B1&done=skirting:S1');
expect(distinct.error).toBeNull();
expect(distinct.state.project.completedBoards).toEqual(['floor:B1', 'skirting:S1']);
});
it('bounds the search, progress list and collapsed sections', () => {
expectInvalid(`?search=${'a'.repeat(URL_LIMITS.searchLength + 1)}`, /at most 200 characters/);
expectInvalid('?collapsed=outline', /unsupported value/);
expectInvalid(
'?collapsed=cuts&collapsed=materials&collapsed=measurements&collapsed=cuts',
/at most 3/,
);
const state = defaultState();
state.project.completedBoards = Array.from(
{ length: URL_LIMITS.completedBoards + 1 },
(_, index) => `floor:B${index + 1}`,
);
expect(() => encodeState(state)).toThrow(/at most 12000/);
expectInvalid(rawLink(state), /at most 12000/);
expectInvalid(
`?done=floor:B${'1'.repeat(URL_LIMITS.identifierLength)}`,
/at most 120 characters/,
);
});
it.each([null, true, 'floor:B4', [false], [{}]])(
'rejects incorrectly typed completed-board sets %j',
(completedBoards) => {
const state = defaultState();
expectInvalid(rawLink({ ...state, project: { ...state.project, completedBoards } }));
},
);
it.each([null, 42, [], {}])('rejects incorrectly typed search input %j', (search) => {
const state = defaultState();
expectInvalid(rawLink({ ...state, view: { ...state.view, search } }));
});
it.each([null, true, 'materials', [42], ['outline']])(
'rejects incorrectly typed or unsupported collapsed sections %j',
(collapsedSections) => {
const state = defaultState();
expectInvalid(rawLink({ ...state, view: { ...state.view, collapsedSections } }));
},
);
it('rejects oversized query strings before parsing fields', () => {
expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/);
});
it('rejects oversized compressed and expanded payloads', () => {
expectInvalid(`#plan=${'a'.repeat(URL_LIMITS.hashLength)}`, /link is too large/);
expectInvalid(
rawLink('a'.repeat(URL_LIMITS.jsonLength + 1)),
/project in this link is too large/,
);
});
});