floor-planner/tests/url-state.test.ts
Andras Schmelczer 8bf3e3b705
Some checks failed
Deploy to Pages / build (push) Failing after 10m45s
Simplify
2026-09-11 23:27:29 +01:00

179 lines
7.1 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 { describe, expect, it } from 'vitest';
import { defaultState, fireplace, rectangle } from '../src/lib/defaults';
import { PERSISTENCE_LIMITS, PROJECT_LIMITS } from '../src/lib/limits';
import { decodeState, encodeState } from '../src/lib/url-state';
const payload = (state: unknown) =>
`?plan=${encodeURIComponent(JSON.stringify({ version: 3, ...(state as object) }))}`;
function expectInvalid(link: string, message?: RegExp) {
const result = decodeState(link);
expect(result.state).toEqual(defaultState());
expect(result.error).toMatch(message ?? /Could not load this plan/);
}
describe('saved plans', () => {
it('starts with independent defaults', () => {
for (const link of ['', '#', '?', '?planner']) {
const first = decodeState(link);
expect(first).toEqual({ state: defaultState(), error: null });
first.state.project.rooms[0].name = 'Edited';
expect(decodeState(link).state.project.rooms[0].name).toBe('Living room');
}
});
it('round-trips one versioned payload, including Unicode and material-qualified selection', () => {
const state = defaultState();
state.project.rooms[0].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.optimisation = 'random';
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
state.view = {
roomId: 'room-2',
selectedPiece: 'skirting:B12',
search: '',
focus: { kind: 'board', material: 'skirting', id: 'S6' },
collapsedSections: ['materials'],
material: 'skirting',
};
const query = encodeState(state);
expect([...new URLSearchParams(query).keys()]).toEqual(['plan']);
expect(decodeState(query)).toEqual({ state, error: null });
expect(encodeState(decodeState(query).state)).toBe(query);
});
it('keeps finite invalid measurements and unfinished outlines for editor feedback', () => {
const state = defaultState();
state.project.rooms[0].walls = [];
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('validates untrusted payloads and discards unknown derived fields', () => {
const state = defaultState();
expect(
decodeState(payload({ ...state, project: { ...state.project, boardCount: 500 } })).state,
).toEqual(state);
for (const invalid of ['1200', null, true, {}, []]) {
expectInvalid(
payload({
...state,
project: { ...state.project, flooring: { ...state.project.flooring, length: invalid } },
}),
/finite number/,
);
}
expectInvalid(payload({ ...state, version: 99 }), /version is unsupported/);
expectInvalid(
payload({ ...state, view: { ...state.view, selectedPiece: 'F1' } }),
/material and an identifier/,
);
expectInvalid(
payload({
...state,
view: { ...state.view, focus: { kind: 'mystery', material: 'flooring', id: 'F1' } },
}),
);
expectInvalid('?plan=%7Bbroken');
expectInvalid('?plan={}&plan={}');
});
it('rejects duplicate identities, excessive collections and long text at import', () => {
const state = defaultState();
for (const rooms of [
[],
[rectangle(), rectangle()],
Array.from({ length: PROJECT_LIMITS.rooms + 1 }, (_, i) => rectangle(`room-${i}`)),
])
expectInvalid(payload({ ...state, project: { ...state.project, rooms } }));
const room = rectangle();
room.walls = Array.from({ length: PROJECT_LIMITS.wallsPerRoom + 1 }, (_, i) => ({
id: `w${i}`,
length: 1,
turn: 90,
doors: [],
}));
expectInvalid(payload({ ...state, project: { ...state.project, rooms: [room] } }));
const longName = rectangle();
longName.name = 'a'.repeat(PROJECT_LIMITS.nameLength + 1);
expectInvalid(payload({ ...state, project: { ...state.project, rooms: [longName] } }));
const doors = rectangle();
doors.walls[0].doors = Array.from({ length: PROJECT_LIMITS.doorsPerWall + 1 }, (_, i) => ({
id: `d${i}`,
offset: 0,
width: 1,
}));
expectInvalid(payload({ ...state, project: { ...state.project, rooms: [doors] } }));
});
it('bounds progress, search and link size', () => {
const state = defaultState();
state.project.completedBoards = Array.from(
{ length: PERSISTENCE_LIMITS.completedBoards + 1 },
(_, i) => `floor:B${i + 1}`,
);
expectInvalid(payload(state));
expectInvalid(
payload({ ...defaultState(), view: { ...defaultState().view, search: 'a'.repeat(201) } }),
);
expectInvalid(`?plan=${'x'.repeat(PERSISTENCE_LIMITS.queryLength)}`, /link is too large/);
expect(() =>
encodeState({
...defaultState(),
view: { ...defaultState().view, search: 'x'.repeat(PERSISTENCE_LIMITS.queryLength) },
}),
).toThrow(/too large/);
});
it('falls back to the first room for stale navigation', () => {
const state = defaultState();
state.view.roomId = 'deleted';
expect(decodeState(payload(state)).state.view.roomId).toBe('room-1');
});
});
describe('version 2 link compatibility', () => {
it('imports measurements, defaults, progress, and explicit locations', () => {
const result = decodeState(
'?version=2&flooring.length=1400&flooring.pattern=random&selectedCut=A2&view=skirting&search=skirting:S6&done=skirting:S6',
);
expect(result.error).toBeNull();
expect(result.state.project.flooring.length).toBe(1400);
expect(result.state.project.flooring.width).toBe(190);
expect(result.state.project.completedBoards).toEqual(['skirting:S6']);
expect(result.state.view.selectedPiece).toBe('skirting:A2');
expect(result.state.view.focus).toEqual({ kind: 'board', material: 'skirting', id: 'S6' });
expect(result.state.view.search).toBe('');
expect(decodeState(encodeState(result.state))).toEqual(result);
});
it('imports an unfinished custom room', () => {
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[0]).toEqual({
id: 'room-1',
name: 'Office',
direction: 0,
walls: [{ id: 'room-1-w1', length: 4000, turn: -90, doors: [] }],
});
});
it.each([
'?flooring.length=NaN',
'?flooring.length=Infinity',
'?flooring.length=',
'?flooring.length=0x10',
'?flooring.pattern=diagonal',
'?flooring.optimisation=quick',
'?version=1',
'?version=3',
'?room.2.id=second',
'?room.0.id=zero',
'?room.one.id=one',
'?room.21.id=excess',
'?flooring.length=1200&flooring.length=1400',
'?skirting.stock=2400',
'?name=%',
'?name=%C0%AF',
'#plan=old',
])('rejects damaged legacy input %s', (link) => expectInvalid(link));
});