179 lines
7.1 KiB
TypeScript
179 lines
7.1 KiB
TypeScript
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));
|
||
});
|