..
This commit is contained in:
parent
9689bfcc18
commit
62a385e4c9
33 changed files with 2649 additions and 809 deletions
|
|
@ -45,7 +45,7 @@ describe('URL state', () => {
|
|||
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' };
|
||||
state.view = { ...state.view, roomId: 'room-2', selectedPiece: 'A12' };
|
||||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
|
|
@ -64,8 +64,13 @@ describe('URL state', () => {
|
|||
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']);
|
||||
expect(decoded.state.view).toEqual({ ...state.view, selectedPiece: 'A4' });
|
||||
expect(Object.keys(decoded.state.view).sort()).toEqual([
|
||||
'collapsedSections',
|
||||
'roomId',
|
||||
'search',
|
||||
'selectedPiece',
|
||||
]);
|
||||
});
|
||||
|
||||
it('canonicalizes property order and discards derived or unknown fields', () => {
|
||||
|
|
@ -341,7 +346,7 @@ describe('URL state', () => {
|
|||
const state = defaultState();
|
||||
state.project.rooms.push(fireplace('second', 'Fireplace room'));
|
||||
state.project.skirting.stockLengths = [2400, 3600];
|
||||
state.view = { roomId: 'second', selectedPiece: 'B2' };
|
||||
state.view = { ...state.view, roomId: 'second', selectedPiece: 'B2' };
|
||||
const legacy = decodeState(rawLink(state));
|
||||
expect(legacy.error).toBeNull();
|
||||
const query = encodeState(legacy.state);
|
||||
|
|
@ -350,6 +355,124 @@ describe('URL state', () => {
|
|||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('stores progress and view preferences as optional readable fields', () => {
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
|
||||
state.view.search = 'F12';
|
||||
state.view.collapsedSections = ['materials', 'measurements'];
|
||||
const query = encodeState(state);
|
||||
expect(query).toContain('done=floor:B4&done=skirting:S6');
|
||||
expect(query).toContain('search=F12&collapsed=materials&collapsed=measurements');
|
||||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
const empty = new URLSearchParams(encodeState(defaultState()));
|
||||
for (const key of ['done', 'search', 'collapsed']) expect(empty.has(key)).toBe(false);
|
||||
});
|
||||
|
||||
it('canonicalizes completion and collapse sets without mutating their inputs', () => {
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = ['skirting:S6', 'floor:B4'];
|
||||
state.view.collapsedSections = ['measurements', 'cuts', 'materials'];
|
||||
const before = structuredClone(state);
|
||||
const query = encodeState(state);
|
||||
const loaded = decodeState(query);
|
||||
expect(loaded.error).toBeNull();
|
||||
expect(loaded.state.project.completedBoards).toEqual(['floor:B4', 'skirting:S6']);
|
||||
expect(loaded.state.view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']);
|
||||
expect(state).toEqual(before);
|
||||
state.project.completedBoards.reverse();
|
||||
state.view.collapsedSections.reverse();
|
||||
expect(encodeState(state)).toBe(query);
|
||||
expect(encodeState(loaded.state)).toBe(query);
|
||||
});
|
||||
|
||||
it('loads old query and fragment links with empty progress and view preferences', () => {
|
||||
const state = defaultState();
|
||||
const old = {
|
||||
project: { ...state.project, completedBoards: undefined },
|
||||
view: { roomId: state.view.roomId, selectedPiece: null },
|
||||
};
|
||||
expect(decodeState(rawLink(old))).toEqual({ state, error: null });
|
||||
expect(decodeState('?flooring.length=1200').state).toEqual(state);
|
||||
const first = defaultState();
|
||||
first.project.completedBoards.push('floor:B4');
|
||||
first.view.collapsedSections.push('materials');
|
||||
expect(defaultState().project.completedBoards).toEqual([]);
|
||||
expect(defaultState().view.collapsedSections).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves Unicode and spaces in the search input independently of selected cuts', () => {
|
||||
const state = defaultState();
|
||||
state.view.search = ' Café 北 F12 / A2 ';
|
||||
state.view.selectedPiece = 'F12';
|
||||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||
expectInvalid('?search=F1&search=F2', /only once/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'B4',
|
||||
'floor:S4',
|
||||
'skirting:B4',
|
||||
'floor:B0',
|
||||
'floor:B04',
|
||||
'floor:B-1',
|
||||
'floor:B1.5',
|
||||
])('rejects invalid completed-board key %s', (key) => {
|
||||
expectInvalid(`?done=${encodeURIComponent(key)}`, /floor:B1 or skirting:S1/);
|
||||
});
|
||||
|
||||
it('rejects duplicate completion and collapse entries rather than counting them twice', () => {
|
||||
expectInvalid('?done=floor:B4&done=floor:B4', /duplicate entries/);
|
||||
expectInvalid('?collapsed=materials&collapsed=materials', /duplicate entries/);
|
||||
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');
|
||||
expect(distinct.error).toBeNull();
|
||||
expect(distinct.state.project.completedBoards).toEqual(['floor:B1', 'skirting:S1']);
|
||||
});
|
||||
|
||||
it('bounds the search, progress list and collapsed sections', () => {
|
||||
expectInvalid(`?search=${'a'.repeat(URL_LIMITS.searchLength + 1)}`, /at most 200 characters/);
|
||||
expectInvalid('?collapsed=outline', /unsupported value/);
|
||||
expectInvalid(
|
||||
'?collapsed=cuts&collapsed=materials&collapsed=measurements&collapsed=cuts',
|
||||
/at most 3/,
|
||||
);
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = Array.from(
|
||||
{ length: URL_LIMITS.completedBoards + 1 },
|
||||
(_, index) => `floor:B${index + 1}`,
|
||||
);
|
||||
expect(() => encodeState(state)).toThrow(/at most 12000/);
|
||||
expectInvalid(rawLink(state), /at most 12000/);
|
||||
expectInvalid(
|
||||
`?done=floor:B${'1'.repeat(URL_LIMITS.identifierLength)}`,
|
||||
/at most 120 characters/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([null, true, 'floor:B4', [false], [{}]])(
|
||||
'rejects incorrectly typed completed-board sets %j',
|
||||
(completedBoards) => {
|
||||
const state = defaultState();
|
||||
expectInvalid(rawLink({ ...state, project: { ...state.project, completedBoards } }));
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, 42, [], {}])('rejects incorrectly typed search input %j', (search) => {
|
||||
const state = defaultState();
|
||||
expectInvalid(rawLink({ ...state, view: { ...state.view, search } }));
|
||||
});
|
||||
|
||||
it.each([null, true, 'materials', [42], ['outline']])(
|
||||
'rejects incorrectly typed or unsupported collapsed sections %j',
|
||||
(collapsedSections) => {
|
||||
const state = defaultState();
|
||||
expectInvalid(rawLink({ ...state, view: { ...state.view, collapsedSections } }));
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects oversized query strings before parsing fields', () => {
|
||||
expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue