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)); });