wip
This commit is contained in:
commit
d8f2407ddf
44 changed files with 10377 additions and 0 deletions
236
tests/url-state.test.ts
Normal file
236
tests/url-state.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue