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