265 lines
11 KiB
TypeScript
265 lines
11 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';
|
||
|
||
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/);
|
||
});
|
||
});
|