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 ).optimisation; expect(() => encodeState(missingOptimisation)).toThrow(/optimisation.*unsupported/i); const missingStockLength = defaultState(); delete ( missingStockLength.project.skirting as Partial ).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/); }); });