reviewed
This commit is contained in:
parent
62a385e4c9
commit
7959fe85cc
29 changed files with 1679 additions and 498 deletions
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue