583 lines
23 KiB
TypeScript
583 lines
23 KiB
TypeScript
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(/^\?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=');
|
||
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.stockLength = 3600;
|
||
state.view = { ...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({ ...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', () => {
|
||
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 invalid board length without inventing inputs', () => {
|
||
const state = defaultState();
|
||
state.project.rooms[0].walls = [];
|
||
state.project.skirting.stockLength = 0;
|
||
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 legacy compression characters', () => {
|
||
const hash = rawLink(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 legacy payloads even when the decoder ignores trailing data', () => {
|
||
const hash = rawLink(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 legacy 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();
|
||
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', () => {
|
||
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('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.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.stockLength).toBe(3600);
|
||
expect(result.state.view.selectedPiece).toBe('F12');
|
||
const reversed = new URLSearchParams([...params].reverse());
|
||
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 a zero board length and empty room while omitting an unselected cut', () => {
|
||
const state = defaultState();
|
||
state.project.skirting.stockLength = 0;
|
||
state.project.rooms[0].walls = [];
|
||
const query = encodeState(state);
|
||
expect(query).toContain('skirting.length=0');
|
||
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 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.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/);
|
||
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.stockLength = 3600;
|
||
state.view = { ...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('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('?skirting.length=2400&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/);
|
||
});
|
||
|
||
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/,
|
||
);
|
||
});
|
||
});
|