This commit is contained in:
Andras Schmelczer 2026-09-11 16:58:56 +01:00
commit 9689bfcc18
23 changed files with 1815 additions and 955 deletions

View file

@ -15,7 +15,7 @@ function expectInvalid(hash: string, message?: RegExp) {
describe('URL state', () => {
it('starts with independent defaults when no link is provided', () => {
for (const hash of ['', '#']) {
for (const hash of ['', '#', '?']) {
const first = decodeState(hash);
expect(first).toEqual({ state: defaultState(), error: null });
first.state.project.rooms[0].name = 'Edited';
@ -26,7 +26,12 @@ describe('URL state', () => {
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(hash).toMatch(/^\?version=1&name=/);
const params = new URLSearchParams(hash);
expect(params.get('flooring.length')).toBe('1200');
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);
@ -101,8 +106,8 @@ describe('URL state', () => {
expect(result.state.view.roomId).toBe(state.project.rooms[0].id);
});
it('accepts URL-escaped compression characters', () => {
const hash = encodeState(defaultState());
it('accepts URL-escaped legacy compression characters', () => {
const hash = rawLink(defaultState());
expect(decodeState(`#plan=${encodeURIComponent(hash.slice(6))}`).state).toEqual(defaultState());
});
@ -111,8 +116,8 @@ describe('URL state', () => {
(hash) => expectInvalid(hash),
);
it('rejects truncated and appended payloads even when the decoder ignores trailing data', () => {
const hash = encodeState(defaultState());
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`);
});
@ -226,6 +231,129 @@ describe('URL state', () => {
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.append('skirting.stock', '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.stockLengths).toEqual([2400, 3600]);
expect(result.state.view.selectedPiece).toBe('F12');
const reversed = new URLSearchParams([...params].reverse());
// Repeated stock values retain their order; every other key can move freely.
reversed.delete('skirting.stock');
params.getAll('skirting.stock').forEach((length) => reversed.append('skirting.stock', length));
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 explicitly empty stocks and rooms while omitting an unselected cut', () => {
const state = defaultState();
state.project.skirting.stockLengths = [];
state.project.rooms[0].walls = [];
const query = encodeState(state);
expect(query).toContain('skirting.stock=');
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 ambiguous duplicate single-value fields but permits multiple 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.stockLengths).toEqual([2400, 3600]);
});
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.stockLengths = [2400, 3600];
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('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(