This commit is contained in:
Andras Schmelczer 2026-09-11 17:57:03 +01:00
commit 7959fe85cc
29 changed files with 1679 additions and 498 deletions

View file

@ -29,6 +29,8 @@ describe('URL 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=');
@ -44,7 +46,7 @@ describe('URL state', () => {
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.project.skirting.stockLength = 3600;
state.view = { ...state.view, roomId: 'room-2', selectedPiece: 'A12' };
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
});
@ -96,10 +98,10 @@ describe('URL state', () => {
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
});
it('allows an unfinished room and an empty stock list without inventing inputs', () => {
it('allows an unfinished room and invalid board length without inventing inputs', () => {
const state = defaultState();
state.project.rooms[0].walls = [];
state.project.skirting.stockLengths = [];
state.project.skirting.stockLength = 0;
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
});
@ -196,7 +198,7 @@ describe('URL state', () => {
);
});
it('rejects too many rooms, walls, doors or stock lengths', () => {
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}`),
@ -217,8 +219,20 @@ describe('URL state', () => {
);
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/);
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', () => {
@ -243,19 +257,16 @@ describe('URL state', () => {
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('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.stockLengths).toEqual([2400, 3600]);
expect(result.state.project.skirting.stockLength).toBe(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));
});
@ -286,12 +297,12 @@ describe('URL state', () => {
expect(new URLSearchParams(encoded).has('room.1.wall.2.length')).toBe(false);
});
it('preserves explicitly empty stocks and rooms while omitting an unselected cut', () => {
it('preserves a zero board length and empty room while omitting an unselected cut', () => {
const state = defaultState();
state.project.skirting.stockLengths = [];
state.project.skirting.stockLength = 0;
state.project.rooms[0].walls = [];
const query = encodeState(state);
expect(query).toContain('skirting.stock=');
expect(query).toContain('skirting.length=0');
expect(query).not.toContain('selectedCut=');
expect(decodeState(query)).toEqual({ state, error: null });
});
@ -313,15 +324,100 @@ describe('URL state', () => {
'?name=%C0%AF',
])('rejects an invalid editable query %s', (query) => expectInvalid(query));
it('rejects ambiguous duplicate single-value fields but permits multiple stock lengths', () => {
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.stockLengths).toEqual([2400, 3600]);
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/);
@ -345,7 +441,7 @@ describe('URL state', () => {
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.project.skirting.stockLength = 3600;
state.view = { ...state.view, roomId: 'second', selectedPiece: 'B2' };
const legacy = decodeState(rawLink(state));
expect(legacy.error).toBeNull();
@ -427,7 +523,7 @@ describe('URL state', () => {
const state = defaultState();
state.project.completedBoards = ['floor:B4', 'floor:B4'];
expect(() => encodeState(state)).toThrow(/duplicate entries/);
const distinct = decodeState('?done=floor:B1&done=skirting:S1');
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']);
});