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(/^\?version=1&name=/); const params = new URLSearchParams(hash); expect(params.get('flooring.length')).toBe('1200'); expect(params.get('skirting.length')).toBe('2400'); expect(params.has('skirting.stock')).toBe(false); expect(params.get('room.1.wall.1.length')).toBe('4800'); expect(params.get('room.1.wall.4.door.1.width')).toBe('826'); expect(hash).not.toContain('#plan='); 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.stockLength = 3600; state.view = { ...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({ ...state.view, selectedPiece: 'A4' }); expect(Object.keys(decoded.state.view).sort()).toEqual([ 'collapsedSections', 'roomId', 'search', '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 invalid board length 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 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 legacy compression characters', () => { const hash = rawLink(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 legacy payloads even when the decoder ignores trailing data', () => { const hash = rawLink(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 legacy 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(); expectInvalid( rawLink({ ...stock, project: { ...stock.project, skirting: { ...stock.project.skirting, stockLength: undefined, stockLengths: Array(URL_LIMITS.legacyStockLengths + 1).fill(2400), }, }, }), /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('uses individually editable fields and canonicalizes query 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 small hand-written query and uses defaults for omitted material fields', () => { 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 storing 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('preserves a zero board length and empty room while omitting an unselected cut', () => { const state = defaultState(); state.project.skirting.stockLength = 0; state.project.rooms[0].walls = []; const query = encodeState(state); expect(query).toContain('skirting.length=0'); expect(query).not.toContain('selectedCut='); expect(decodeState(query)).toEqual({ state, error: null }); }); it.each([ '?flooring.length=NaN', '?flooring.length=Infinity', '?flooring.length=1e999', '?flooring.length=', '?flooring.length=true', '?flooring.length=0x10', '?flooring.pattern=diagonal', '?room.1.direction=45', '?version=2', '?room.1.wall.1.turn=90', '?room.1.wall.1.length=4000&room.1.wall.1.door.1.offset=300', '?skirting.stock=&skirting.stock=2400', '?name=%', '?name=%C0%AF', ])('rejects an invalid editable query %s', (query) => expectInvalid(query)); it('rejects duplicate single-value fields and migrates repeated legacy stock lengths', () => { 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/); const result = decodeState('?skirting.stock=2400&skirting.stock=3600'); expect(result.error).toBeNull(); expect(result.state.project.skirting.stockLength).toBe(2400); expectInvalid('?skirting.length=2400&skirting.length=3600', /only once/); }); it.each([ { lengths: [3600, 2400], chosen: 3600, completed: [] }, { lengths: [2400, 3600], chosen: 2400, completed: [] }, { lengths: [3600], chosen: 3600, completed: [] }, { lengths: [3600, 3600], chosen: 3600, completed: [] }, { lengths: [], chosen: 0, completed: [] }, ])( 'migrates old stock sizes $lengths consistently in query and compressed links', ({ lengths, chosen, completed }) => { const state = defaultState(); state.project.completedBoards = ['floor:B4', 'skirting:S6']; const old = { ...state, project: { ...state.project, skirting: { ...state.project.skirting, stockLength: undefined, stockLengths: lengths }, }, }; const query = new URLSearchParams(); for (const length of lengths.length ? lengths : ['']) query.append('skirting.stock', String(length)); for (const board of state.project.completedBoards) query.append('done', board); state.project.skirting.stockLength = chosen; state.project.completedBoards = completed; for (const link of [rawLink(old), `?${query}`]) { const result = decodeState(link); expect(result).toEqual({ state, error: null }); const canonical = encodeState(result.state); expect(canonical).toContain(`skirting.length=${chosen}`); expect(canonical).not.toContain('skirting.stock'); expect(decodeState(canonical)).toEqual(result); } }, ); it.each(['?done=skirting:S4', '?skirting.stock=2400&done=skirting:S4'])( 'clears completion from earlier calculations in %s and preserves new progress after migration', (link) => { const migrated = decodeState(link); expect(migrated.error).toBeNull(); expect(migrated.state.project.completedBoards).toEqual([]); migrated.state.project.completedBoards = ['skirting:S4']; expect(decodeState(encodeState(migrated.state))).toEqual(migrated); }, ); it('prefers the current length field over legacy stock lists without clearing progress', () => { const state = defaultState(); state.project.skirting.stockLength = 4800; state.project.completedBoards = ['skirting:S6']; const legacy = { ...state, project: { ...state.project, skirting: { ...state.project.skirting, stockLengths: [3600, 2400] }, }, }; expect(decodeState(rawLink(legacy))).toEqual({ state, error: null }); expect( decodeState('?skirting.length=4800&skirting.stock=3600&skirting.stock=2400&done=skirting:S6'), ).toEqual({ state, error: null }); }); it.each([null, '2400', true, {}, [], NaN, Infinity])( 'rejects invalid skirting board length %j', (stockLength) => { const state = defaultState(); expectInvalid( rawLink({ ...state, project: { ...state.project, skirting: { ...state.project.skirting, stockLength } }, }), /finite number/, ); }, ); it.each(['', 'NaN', 'Infinity', 'true', '0x10'])( 'rejects invalid editable skirting length %s', (length) => { expectInvalid(`?skirting.length=${length}`, /finite number/); }, ); it('rejects sparse, zero, malformed and excessive room, wall and door 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.21.id=excess', /at most 20/); expectInvalid('?room.1.wall.101.length=4000', /at most 100/); expectInvalid('?room.1.wall.1.length=4000&room.1.wall.1.door.31.width=500', /at most 30/); expectInvalid(`?${Array(31).fill('skirting.stock=2400').join('&')}`, /at most 30/); expectInvalid('?room.1.id=same&room.2.id=same', /duplicate identifiers/); }); it('discards unrecognized query fields instead of storing derived state', () => { const decoded = decodeState('?flooring.length=1400&boardCount=999&cache=old&room.8.cache=old'); expect(decoded.error).toBeNull(); const canonical = encodeState(decoded.state); expect(canonical).toContain('flooring.length=1400'); expect(canonical).not.toMatch(/boardCount|cache/); expect(decoded.state.project.rooms).toEqual(defaultState().project.rooms); }); it('converts a legacy shared plan to readable fields without losing inputs or selections', () => { const state = defaultState(); state.project.rooms.push(fireplace('second', 'Fireplace room')); state.project.skirting.stockLength = 3600; state.view = { ...state.view, roomId: 'second', selectedPiece: 'B2' }; const legacy = decodeState(rawLink(state)); expect(legacy.error).toBeNull(); const query = encodeState(legacy.state); expect(query).toContain('room.2.wall.4.turn=-90'); expect(query).toContain('selectedRoom=second&selectedCut=B2'); expect(decodeState(query)).toEqual({ state, error: null }); }); it('stores progress and view preferences as optional readable fields', () => { const state = defaultState(); state.project.completedBoards = ['floor:B4', 'skirting:S6']; state.view.search = 'F12'; state.view.collapsedSections = ['materials', 'measurements']; const query = encodeState(state); expect(query).toContain('done=floor:B4&done=skirting:S6'); expect(query).toContain('search=F12&collapsed=materials&collapsed=measurements'); expect(decodeState(query)).toEqual({ state, error: null }); const empty = new URLSearchParams(encodeState(defaultState())); for (const key of ['done', 'search', 'collapsed']) expect(empty.has(key)).toBe(false); }); it('canonicalizes completion and collapse sets without mutating their inputs', () => { const state = defaultState(); state.project.completedBoards = ['skirting:S6', 'floor:B4']; state.view.collapsedSections = ['measurements', 'cuts', 'materials']; const before = structuredClone(state); const query = encodeState(state); const loaded = decodeState(query); expect(loaded.error).toBeNull(); expect(loaded.state.project.completedBoards).toEqual(['floor:B4', 'skirting:S6']); expect(loaded.state.view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']); expect(state).toEqual(before); state.project.completedBoards.reverse(); state.view.collapsedSections.reverse(); expect(encodeState(state)).toBe(query); expect(encodeState(loaded.state)).toBe(query); }); it('loads old query and fragment links with empty progress and view preferences', () => { const state = defaultState(); const old = { project: { ...state.project, completedBoards: undefined }, view: { roomId: state.view.roomId, selectedPiece: null }, }; expect(decodeState(rawLink(old))).toEqual({ state, error: null }); expect(decodeState('?flooring.length=1200').state).toEqual(state); const first = defaultState(); first.project.completedBoards.push('floor:B4'); first.view.collapsedSections.push('materials'); expect(defaultState().project.completedBoards).toEqual([]); expect(defaultState().view.collapsedSections).toEqual([]); }); it('preserves Unicode and spaces in the search input independently of selected cuts', () => { const state = defaultState(); state.view.search = ' Café 北 F12 / A2 '; state.view.selectedPiece = 'F12'; expect(decodeState(encodeState(state))).toEqual({ state, error: null }); expectInvalid('?search=F1&search=F2', /only once/); }); it.each([ '', 'B4', 'floor:S4', 'skirting:B4', 'floor:B0', 'floor:B04', 'floor:B-1', 'floor:B1.5', ])('rejects invalid completed-board key %s', (key) => { expectInvalid(`?done=${encodeURIComponent(key)}`, /floor:B1 or skirting:S1/); }); it('rejects duplicate completion and collapse entries rather than counting them twice', () => { expectInvalid('?done=floor:B4&done=floor:B4', /duplicate entries/); expectInvalid('?collapsed=materials&collapsed=materials', /duplicate entries/); const state = defaultState(); state.project.completedBoards = ['floor:B4', 'floor:B4']; expect(() => encodeState(state)).toThrow(/duplicate entries/); const distinct = decodeState('?skirting.length=2400&done=floor:B1&done=skirting:S1'); expect(distinct.error).toBeNull(); expect(distinct.state.project.completedBoards).toEqual(['floor:B1', 'skirting:S1']); }); it('bounds the search, progress list and collapsed sections', () => { expectInvalid(`?search=${'a'.repeat(URL_LIMITS.searchLength + 1)}`, /at most 200 characters/); expectInvalid('?collapsed=outline', /unsupported value/); expectInvalid( '?collapsed=cuts&collapsed=materials&collapsed=measurements&collapsed=cuts', /at most 3/, ); const state = defaultState(); state.project.completedBoards = Array.from( { length: URL_LIMITS.completedBoards + 1 }, (_, index) => `floor:B${index + 1}`, ); expect(() => encodeState(state)).toThrow(/at most 12000/); expectInvalid(rawLink(state), /at most 12000/); expectInvalid( `?done=floor:B${'1'.repeat(URL_LIMITS.identifierLength)}`, /at most 120 characters/, ); }); it.each([null, true, 'floor:B4', [false], [{}]])( 'rejects incorrectly typed completed-board sets %j', (completedBoards) => { const state = defaultState(); expectInvalid(rawLink({ ...state, project: { ...state.project, completedBoards } })); }, ); it.each([null, 42, [], {}])('rejects incorrectly typed search input %j', (search) => { const state = defaultState(); expectInvalid(rawLink({ ...state, view: { ...state.view, search } })); }); it.each([null, true, 'materials', [42], ['outline']])( 'rejects incorrectly typed or unsupported collapsed sections %j', (collapsedSections) => { const state = defaultState(); expectInvalid(rawLink({ ...state, view: { ...state.view, collapsedSections } })); }, ); it('rejects oversized query strings before parsing fields', () => { expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/); }); 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/, ); }); });