floor-planner/tests/url-state.test.ts
2026-09-11 22:14:24 +01:00

265 lines
11 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';
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('URL state', () => {
it('starts with independent defaults when no link is provided', () => {
for (const link of ['', '#', '?']) {
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 current inputs exactly and produces a stable readable link', () => {
const state = defaultState();
const query = encodeState(state);
expect(query).toMatch(/^\?version=2&name=/);
const params = new URLSearchParams(query);
expect(params.get('flooring.length')).toBe('1200');
expect(params.get('flooring.optimisation')).toBe('cuts');
expect(params.get('skirting.length')).toBe('2400');
expect(params.get('room.1.wall.1.length')).toBe('4800');
expect(params.get('room.1.wall.4.door.1.width')).toBe('826');
expect(query).not.toContain('#plan=');
expect(decodeState(query)).toEqual({ state, error: null });
expect(encodeState(decodeState(query).state)).toBe(query);
expect(encodeState(structuredClone(state))).toBe(query);
});
it('round-trips Unicode, rooms, doors, materials, progress 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.flooring.optimisation = 'random';
state.project.skirting.stockLength = 3600;
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
state.view = {
roomId: 'room-2',
selectedPiece: 'A12',
search: 'A12',
collapsedSections: ['materials'],
material: 'skirting',
};
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
});
it('canonicalizes property order and discards unknown derived fields', () => {
const state = defaultState();
const reordered = {
view: { ...state.view, derived: 6 },
project: { ...state.project, boardCount: 500 },
cache: [],
};
expect(encodeState(reordered)).toBe(encodeState(state));
const params = new URLSearchParams(encodeState(state));
params.set('boardCount', '500');
params.set('room.1.polygon', 'derived');
expect(decodeState(`?${params}`)).toEqual({ state, error: null });
});
it('preserves finite domain-invalid measurements for planner 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 unfinished current projects 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 params = new URLSearchParams(encodeState(defaultState()));
params.set('selectedRoom', 'deleted-room');
const result = decodeState(`?${params}`);
expect(result.error).toBeNull();
expect(result.state.view.roomId).toBe(result.state.project.rooms[0].id);
});
it.each(['#plan=old', '#other=value', 'version=2'])('rejects non-query links %s', (link) => {
expectInvalid(link, /starting with \?/);
});
it.each(['?version=1', '?version=3'])('rejects unsupported schema link %s', (link) => {
expectInvalid(link, /version is unsupported/);
});
it('requires every field in the current in-memory contract', () => {
const missingOptimisation = defaultState();
delete (
missingOptimisation.project.flooring as Partial<typeof missingOptimisation.project.flooring>
).optimisation;
expect(() => encodeState(missingOptimisation)).toThrow(/optimisation.*unsupported/i);
const missingStockLength = defaultState();
delete (
missingStockLength.project.skirting as Partial<typeof missingStockLength.project.skirting>
).stockLength;
expect(() => encodeState(missingStockLength)).toThrow(/finite number/);
});
it('rejects incorrectly typed and nonfinite measurements when reading or writing', () => {
for (const query of [
'?flooring.length=NaN',
'?flooring.length=Infinity',
'?flooring.length=1e999',
'?flooring.length=',
'?flooring.length=true',
'?flooring.length=0x10',
])
expectInvalid(query, /finite number/);
const state = defaultState();
state.project.flooring.length = '1200' as never;
expect(() => encodeState(state)).toThrow(/finite number/);
for (const invalid of [NaN, Infinity, -Infinity]) {
state.project.flooring.length = invalid;
expect(() => encodeState(state)).toThrow(/finite number/);
}
});
it.each(['?flooring.pattern=diagonal', '?flooring.optimisation=quick', '?room.1.direction=45'])(
'rejects an invalid current enum in %s',
(query) => expectInvalid(query),
);
it('uses individually editable fields and canonical 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 hand-written partial query with current defaults', () => {
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 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('rejects duplicate fields and the retired stock-list field', () => {
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/);
expectInvalid('?skirting.length=2400&skirting.length=3600', /only once/);
expectInvalid('?skirting.stock=2400', /retired skirting stock-list/);
});
it('rejects sparse, zero, malformed and excessive 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.${PROJECT_LIMITS.rooms + 1}.id=excess`, /at most 20/);
expectInvalid(`?room.1.wall.${PROJECT_LIMITS.wallsPerRoom + 1}.length=4000`, /at most 100/);
expectInvalid(
`?room.1.wall.1.length=4000&room.1.wall.1.door.${PROJECT_LIMITS.doorsPerWall + 1}.width=500`,
/at most 30/,
);
});
it('rejects impossible collections and oversized strings before encoding', () => {
const empty = defaultState();
empty.project.rooms = [];
expect(() => encodeState(empty)).toThrow(/at least one room/);
const duplicate = defaultState();
duplicate.project.rooms.push(rectangle());
expect(() => encodeState(duplicate)).toThrow(/duplicate identifiers/);
const rooms = defaultState();
rooms.project.rooms = Array.from({ length: PROJECT_LIMITS.rooms + 1 }, (_, index) =>
rectangle(`room-${index}`),
);
expect(() => encodeState(rooms)).toThrow(/at most 20/);
const walls = defaultState();
walls.project.rooms[0].walls = Array.from(
{ length: PROJECT_LIMITS.wallsPerRoom + 1 },
(_, index) => ({ id: `wall-${index}`, length: 1, turn: 90, doors: [] }),
);
expect(() => encodeState(walls)).toThrow(/at most 100/);
const doors = defaultState();
doors.project.rooms[0].walls[0].doors = Array.from(
{ length: PROJECT_LIMITS.doorsPerWall + 1 },
(_, index) => ({ id: `door-${index}`, offset: index * 10, width: 5 }),
);
expect(() => encodeState(doors)).toThrow(/at most 30/);
const name = defaultState();
name.project.name = 'a'.repeat(PROJECT_LIMITS.nameLength + 1);
expect(() => encodeState(name)).toThrow(/at most 200 characters/);
});
it('bounds progress, search and total query length', () => {
const state = defaultState();
state.project.completedBoards = Array.from(
{ length: PERSISTENCE_LIMITS.completedBoards + 1 },
(_, index) => `floor:B${index + 1}`,
);
expect(() => encodeState(state)).toThrow(/at most 12000/);
expectInvalid(
`?search=${'a'.repeat(PERSISTENCE_LIMITS.searchLength + 1)}`,
/at most 200 characters/,
);
expectInvalid(`?name=${'x'.repeat(PERSISTENCE_LIMITS.queryLength)}`, /link is too large/);
});
it('rejects damaged percent encoding', () => {
expectInvalid('?name=%', /invalid URL encoding/);
expectInvalid('?name=%C0%AF', /invalid URL encoding/);
});
});