floor-planner/tests/url-state.test.ts
2026-09-11 16:09:28 +01:00

236 lines
8.7 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(/^#plan=[A-Za-z0-9+$-]+$/);
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.stockLengths = [2400, 3600, 4800];
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({ roomId: state.view.roomId, selectedPiece: 'A4' });
expect(Object.keys(decoded.state.view).sort()).toEqual(['roomId', '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 an empty stock list without inventing inputs', () => {
const state = defaultState();
state.project.rooms[0].walls = [];
state.project.skirting.stockLengths = [];
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 compression characters', () => {
const hash = encodeState(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 payloads even when the decoder ignores trailing data', () => {
const hash = encodeState(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 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();
stock.project.skirting.stockLengths = Array(URL_LIMITS.stockLengths + 1).fill(2400);
expectInvalid(rawLink(stock), /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('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/,
);
});
});