better
This commit is contained in:
parent
b9485e4097
commit
0c0f08b350
45 changed files with 1565 additions and 1579 deletions
|
|
@ -9,19 +9,30 @@ import type { FlooringPiece, FlooringPlan, SkirtingPiece, SkirtingPlan } from '.
|
|||
const project = defaultProject();
|
||||
project.rooms.push(rectangle('room-2', 'Office'));
|
||||
function floorPiece(id: string, stockId: string, roomId = 'room-1', cut = true): FlooringPiece {
|
||||
const length = cut ? 400 : 1200;
|
||||
return {
|
||||
id,
|
||||
stockId,
|
||||
roomId,
|
||||
cut,
|
||||
length: cut ? 400 : 1200,
|
||||
length,
|
||||
width: 190,
|
||||
blankLength: length,
|
||||
blankWidth: 190,
|
||||
row: 1,
|
||||
column: 1,
|
||||
polygon: [],
|
||||
template: [],
|
||||
template: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: length, y: 0 },
|
||||
{ x: length, y: 190 },
|
||||
{ x: 0, y: 190 },
|
||||
],
|
||||
shaped: false,
|
||||
reused: false,
|
||||
factoryLeft: false,
|
||||
factoryRight: false,
|
||||
ripEdge: null,
|
||||
};
|
||||
}
|
||||
const flooring: FlooringPlan = {
|
||||
|
|
@ -46,11 +57,18 @@ const flooring: FlooringPlan = {
|
|||
issues: [],
|
||||
};
|
||||
function skirtPiece(id: string, stockId: string, roomId = 'room-1', length = 900): SkirtingPiece {
|
||||
const template: SkirtingPiece['template'] = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: length, y: 0 },
|
||||
{ x: length, y: 18 },
|
||||
{ x: 0, y: 18 },
|
||||
];
|
||||
return {
|
||||
id,
|
||||
stockId,
|
||||
roomId,
|
||||
length,
|
||||
blankLength: length,
|
||||
backLength: length,
|
||||
wallId: 'w1',
|
||||
wallIndex: 0,
|
||||
|
|
@ -60,6 +78,13 @@ function skirtPiece(id: string, stockId: string, roomId = 'room-1', length = 900
|
|||
rightMitre: 0,
|
||||
leftType: 'square',
|
||||
rightType: 'square',
|
||||
template,
|
||||
installedTemplate: template,
|
||||
visibleLength: length,
|
||||
leftOverlap: 0,
|
||||
rightOverlap: 0,
|
||||
leftBevel: 0,
|
||||
rightBevel: 0,
|
||||
};
|
||||
}
|
||||
const skirting: SkirtingPlan = {
|
||||
|
|
|
|||
|
|
@ -11,19 +11,31 @@ import { planSkirting } from '../src/lib/skirting';
|
|||
import type { FlooringPiece, FlooringPlan } from '../src/lib/types';
|
||||
|
||||
function piece(id: string, stockId: string, changes: Partial<FlooringPiece> = {}): FlooringPiece {
|
||||
const length = changes.length ?? 600;
|
||||
const width = changes.width ?? 190;
|
||||
return {
|
||||
id,
|
||||
roomId: 'room-1',
|
||||
row: 1,
|
||||
column: 1,
|
||||
polygon: [],
|
||||
length: 600,
|
||||
width: 190,
|
||||
length,
|
||||
width,
|
||||
blankLength: length,
|
||||
blankWidth: width,
|
||||
stockId,
|
||||
reused: false,
|
||||
cut: true,
|
||||
shaped: false,
|
||||
template: [],
|
||||
template: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: length, y: 0 },
|
||||
{ x: length, y: width },
|
||||
{ x: 0, y: width },
|
||||
],
|
||||
factoryLeft: false,
|
||||
factoryRight: false,
|
||||
ripEdge: null,
|
||||
...changes,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { CutDialog } from '../src/components/CutDialog';
|
|||
import { planSkirting } from '../src/lib/skirting';
|
||||
import { skirtingEndLabel } from '../src/lib/cut-list';
|
||||
import { defaultProject } from '../src/lib/defaults';
|
||||
import { dimensionedOutline, flooringTemplate, skirtingEndBoxes } from '../src/lib/cut-preview';
|
||||
import { dimensionedOutline, skirtingEndBoxes } from '../src/lib/cut-preview';
|
||||
import { cutMm, degree } from '../src/lib/format';
|
||||
import type { FlooringPiece, Point, SkirtingPiece } from '../src/lib/types';
|
||||
|
||||
|
|
@ -18,13 +18,23 @@ const floor: FlooringPiece = {
|
|||
polygon: [],
|
||||
length: 600.001,
|
||||
width: 190,
|
||||
blankLength: 600.5,
|
||||
blankWidth: 190,
|
||||
stockId: 'B1',
|
||||
reused: false,
|
||||
cut: true,
|
||||
shaped: false,
|
||||
template: [],
|
||||
template: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 600.001, y: 0 },
|
||||
{ x: 600.001, y: 190 },
|
||||
{ x: 0, y: 190 },
|
||||
],
|
||||
factoryLeft: false,
|
||||
factoryRight: false,
|
||||
ripEdge: null,
|
||||
};
|
||||
const template: Point[] = [
|
||||
const template: SkirtingPiece['template'] = [
|
||||
{ x: 18, y: 0 },
|
||||
{ x: 1182, y: 0 },
|
||||
{ x: 1200, y: 18 },
|
||||
|
|
@ -38,6 +48,7 @@ const skirt: SkirtingPiece = {
|
|||
start: { x: 0, y: 0 },
|
||||
end: { x: 1164, y: 0 },
|
||||
length: 1200,
|
||||
blankLength: 1200,
|
||||
backLength: 1164,
|
||||
leftMitre: 45,
|
||||
rightMitre: 45,
|
||||
|
|
@ -45,11 +56,17 @@ const skirt: SkirtingPiece = {
|
|||
rightType: 'external',
|
||||
stockId: 'S1',
|
||||
template,
|
||||
installedTemplate: template,
|
||||
visibleLength: 1200,
|
||||
leftOverlap: 0,
|
||||
rightOverlap: 0,
|
||||
leftBevel: -45,
|
||||
rightBevel: 45,
|
||||
};
|
||||
|
||||
describe('cut shape previews', () => {
|
||||
it('keeps precise geometry while rounding displayed cuts up to half millimetres', () => {
|
||||
const points = flooringTemplate(floor);
|
||||
const points = floor.template;
|
||||
expect(points).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 600.001, y: 0 },
|
||||
|
|
@ -73,6 +90,12 @@ describe('cut shape previews', () => {
|
|||
width: 190.1,
|
||||
blankLength: 597.2,
|
||||
blankWidth: 190.2,
|
||||
template: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 597.2, y: 0 },
|
||||
{ x: 597.2, y: 190.1 },
|
||||
{ x: 0, y: 190.1 },
|
||||
],
|
||||
reused: true,
|
||||
};
|
||||
const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece }));
|
||||
|
|
@ -289,8 +312,8 @@ describe('cut shape previews', () => {
|
|||
expect(skirtingEndLabel({ ...piece, rightType: 'internal', rightMitre: 30 }, 'right')).toBe(
|
||||
'Receive cope · wall-fit 30°',
|
||||
);
|
||||
const prepared = piece.template!;
|
||||
const installed = piece.installedTemplate!;
|
||||
const prepared = piece.template;
|
||||
const installed = piece.installedTemplate;
|
||||
expect(prepared[0].x).toBeLessThan(installed[0].x);
|
||||
expect(prepared[3].x).toBeCloseTo(installed[3].x);
|
||||
const compact = renderToStaticMarkup(
|
||||
|
|
|
|||
4
tests/fixtures/angled-room-f8.json
vendored
4
tests/fixtures/angled-room-f8.json
vendored
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"project": {
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"name": "My flooring project",
|
||||
"rooms": [
|
||||
{
|
||||
|
|
@ -106,6 +106,7 @@
|
|||
"width": 190,
|
||||
"gap": 10,
|
||||
"pattern": "random",
|
||||
"optimisation": "cuts",
|
||||
"minOffset": 0.1,
|
||||
"maxOffset": 1000,
|
||||
"minLength": 0.1,
|
||||
|
|
@ -123,6 +124,7 @@
|
|||
},
|
||||
"view": {
|
||||
"roomId": "room-2",
|
||||
"material": "flooring",
|
||||
"selectedPiece": "F8",
|
||||
"search": "F8",
|
||||
"collapsedSections": []
|
||||
|
|
|
|||
11
tests/fixtures/angled-room.json
vendored
11
tests/fixtures/angled-room.json
vendored
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"project": {
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"name": "My flooring project",
|
||||
"rooms": [
|
||||
{
|
||||
|
|
@ -94,6 +94,7 @@
|
|||
"width": 190,
|
||||
"gap": 10,
|
||||
"pattern": "random",
|
||||
"optimisation": "cuts",
|
||||
"minOffset": 0.1,
|
||||
"maxOffset": 1000,
|
||||
"minLength": 0.1,
|
||||
|
|
@ -106,10 +107,14 @@
|
|||
"minLength": 200,
|
||||
"kerf": 3
|
||||
},
|
||||
"tolerance": 50
|
||||
"tolerance": 50,
|
||||
"completedBoards": []
|
||||
},
|
||||
"view": {
|
||||
"roomId": "room-2",
|
||||
"selectedPiece": null
|
||||
"material": "flooring",
|
||||
"selectedPiece": null,
|
||||
"search": "",
|
||||
"collapsedSections": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const settings: FlooringSettings = {
|
|||
width: 200,
|
||||
gap: 10,
|
||||
pattern: 'brick',
|
||||
optimisation: 'cuts',
|
||||
minOffset: 300,
|
||||
maxOffset: 600,
|
||||
minLength: 200,
|
||||
|
|
@ -68,6 +69,18 @@ function expectStockFits(plan: FlooringPlan, s: FlooringSettings) {
|
|||
expect(used + Math.max(0, pieces.length - 1) * s.kerf).toBeLessThanOrEqual(s.length + 0.02);
|
||||
expect(stock.remaining).toBeGreaterThanOrEqual(0);
|
||||
expect(used + stock.remaining).toBeLessThanOrEqual(s.length + 0.02);
|
||||
// Crosscuts cannot create factory joints: each purchased board supplies one of each end.
|
||||
for (const end of ['factoryLeft', 'factoryRight'] as const) {
|
||||
expect(
|
||||
pieces.filter((piece) => piece[end]).length,
|
||||
`${stock.id} allocates its ${end} more than once`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
for (const piece of pieces.filter((piece) => piece.factoryLeft && piece.factoryRight)) {
|
||||
expect(piece.length).toBeCloseTo(s.length, 3);
|
||||
expect(piece.reused).toBe(false);
|
||||
expect(pieces).toHaveLength(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
function expectOffsets(plan: FlooringPlan, s: FlooringSettings) {
|
||||
|
|
@ -137,19 +150,82 @@ describe('laminate planning', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not reuse a cut end where an interior board needs two factory ends', () => {
|
||||
const plan = planFlooring([room()], settings, 1);
|
||||
for (const row of plan.rows) {
|
||||
const pieces = row.pieceIds
|
||||
.map((id) => plan.pieces.find((piece) => piece.id === id)!)
|
||||
.sort((a, b) => a.column - b.column);
|
||||
for (const interior of pieces.slice(1, -1)) {
|
||||
expect(interior.length).toBe(settings.length);
|
||||
expect(interior.reused).toBe(false);
|
||||
expect(interior.factoryLeft).toBe(true);
|
||||
expect(interior.factoryRight).toBe(true);
|
||||
}
|
||||
}
|
||||
describe.each([
|
||||
['brick', 'cuts'],
|
||||
['random', 'cuts'],
|
||||
['random', 'random'],
|
||||
] as const)('%s pattern, %s optimisation: factory short ends', (pattern, optimisation) => {
|
||||
const s = { ...settings, pattern, optimisation };
|
||||
|
||||
it.each([0, 90] as const)(
|
||||
'never packs three row ends onto one board, with boards running at %i degrees',
|
||||
(direction) => {
|
||||
const r = direction === 0 ? room(3920, 1620) : room(1620, 3920);
|
||||
r.direction = direction;
|
||||
const plan = planFlooring([r], s, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(plan.rows).toHaveLength(8);
|
||||
expect(plan.reusedCount).toBeGreaterThan(0);
|
||||
for (const row of plan.rows) {
|
||||
const position = (polygon: Point[]) =>
|
||||
Math.min(...polygon.map((point) => (direction === 0 ? point.x : point.y)));
|
||||
const pieces = row.pieceIds
|
||||
.map((id) => plan.pieces.find((piece) => piece.id === id)!)
|
||||
.sort((a, b) => position(a.polygon) - position(b.polygon));
|
||||
expect(pieces.length).toBeGreaterThan(2);
|
||||
// Derive the required joints from installed positions, independently of allocation flags.
|
||||
pieces.forEach((piece, index) => {
|
||||
expect(piece.factoryLeft, `${piece.id}: left joint`).toBe(index > 0);
|
||||
expect(piece.factoryRight, `${piece.id}: right joint`).toBe(index < pieces.length - 1);
|
||||
});
|
||||
}
|
||||
const shortestEnds = plan.pieces
|
||||
.filter((piece) => piece.factoryLeft !== piece.factoryRight)
|
||||
.map((piece) => piece.length)
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, 3);
|
||||
expect(shortestEnds).toHaveLength(3);
|
||||
// Three ends fit by length, including kerf, but cannot share only two factory joints.
|
||||
expect(shortestEnds.reduce((sum, length) => sum + length, 0) + 2 * s.kerf).toBeLessThan(
|
||||
s.length,
|
||||
);
|
||||
for (const stock of plan.stocks) expect(stock.pieceIds.length).toBeLessThanOrEqual(2);
|
||||
expectStockFits(plan, s);
|
||||
expectCoverage(plan, r, s.gap);
|
||||
},
|
||||
);
|
||||
|
||||
it('allows three pieces when the middle cut spans wall to wall and the outer cuts keep opposite ends', () => {
|
||||
const first = room(1620, 220, 'first');
|
||||
const middle = room(220, 414, 'middle');
|
||||
middle.direction = 90;
|
||||
const last = room(1620, 220, 'last');
|
||||
const rooms = [first, middle, last];
|
||||
const plan = planFlooring(rooms, s, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
const triples = plan.stocks.filter((stock) => stock.pieceIds.length === 3);
|
||||
expect(triples).toHaveLength(1);
|
||||
const pieces = triples[0].pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
|
||||
expect(
|
||||
pieces.map((piece) => ({
|
||||
room: piece.roomId,
|
||||
length: piece.length,
|
||||
left: piece.factoryLeft,
|
||||
right: piece.factoryRight,
|
||||
})),
|
||||
).toEqual([
|
||||
{ room: 'first', length: 400, left: true, right: false },
|
||||
{ room: 'middle', length: 394, left: false, right: false },
|
||||
{ room: 'last', length: 400, left: false, right: true },
|
||||
]);
|
||||
expect(plan.pieces.filter((piece) => piece.roomId === middle.id)).toEqual([pieces[1]]);
|
||||
expect(pieces.map((piece) => piece.reused)).toEqual([false, true, true]);
|
||||
expect(plan.boardCount).toBe(3);
|
||||
expect(plan.cuts).toBe(2);
|
||||
expect(triples[0].remaining).toBe(0);
|
||||
expectStockFits(plan, s);
|
||||
for (const r of rooms) expectCoverage(plan, r, s.gap);
|
||||
});
|
||||
});
|
||||
|
||||
it('produces exact concave fireplace templates and keeps the gap at its external corners', () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import baseline from '../benchmarks/baseline.json';
|
||||
import { planFlooringOptions } from '../src/lib/flooring';
|
||||
import { planSkirting } from '../src/lib/skirting';
|
||||
import { defaultProject, rectangle } from '../src/lib/defaults';
|
||||
import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry';
|
||||
import type { FlooringPlan, Project } from '../src/lib/types';
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ function validateFlooring(plan: FlooringPlan, project: Project) {
|
|||
stock.pieceIds.forEach((id, index) => {
|
||||
const piece = byId.get(id)!;
|
||||
expect(piece.stockId).toBe(stock.id);
|
||||
expect(piece.blankLength!).toBeLessThanOrEqual(remaining + 0.01);
|
||||
expect(piece.blankLength).toBeLessThanOrEqual(remaining + 0.01);
|
||||
expect(!piece.factoryLeft || left).toBe(true);
|
||||
expect(!piece.factoryRight || right).toBe(true);
|
||||
expect(piece.reused).toBe(index > 0);
|
||||
|
|
@ -38,7 +39,7 @@ function validateFlooring(plan: FlooringPlan, project: Project) {
|
|||
crosscuts += Number(cut);
|
||||
expect(piece.cut).toBe(cut || piece.shaped || !!piece.ripEdge);
|
||||
if (cut) {
|
||||
remaining = Math.round(Math.max(0, remaining - piece.blankLength! - s.kerf) * 1000) / 1000;
|
||||
remaining = Math.round(Math.max(0, remaining - piece.blankLength - s.kerf) * 1000) / 1000;
|
||||
if (piece.factoryRight) right = false;
|
||||
else left = false;
|
||||
} else {
|
||||
|
|
@ -136,9 +137,21 @@ describe('measured optimiser quality', () => {
|
|||
totals.pattern += flooringMetrics(options.random, c.project.flooring).patternPenalty;
|
||||
totals.skirtingCuts += skirt.cuts;
|
||||
}
|
||||
expect(totals.cuts).toBeLessThanOrEqual(1840); // Original: 2177.
|
||||
expect(totals.randomCuts).toBeLessThanOrEqual(2130);
|
||||
expect(totals.pattern).toBeLessThanOrEqual(129); // Original: 265.
|
||||
expect(totals.cuts).toBeLessThanOrEqual(1741); // Original: 2177.
|
||||
expect(totals.randomCuts).toBeLessThanOrEqual(1994);
|
||||
expect(totals.pattern).toBeLessThanOrEqual(113); // Original: 265.
|
||||
expect(totals.skirtingCuts).toBeLessThanOrEqual(1436); // Original: 1447.
|
||||
});
|
||||
|
||||
it('packs reserved offcuts before applying the 2000-board cap on a 500-row floor', () => {
|
||||
const project = defaultProject();
|
||||
project.flooring.pattern = 'brick';
|
||||
project.rooms = [rectangle('limit', 'Limit', 4520, 95020)];
|
||||
const plan = planFlooringOptions(project.rooms, project.flooring, 1).cuts;
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.rows).toHaveLength(500);
|
||||
expect(plan.boardCount).toBe(2000);
|
||||
expect(plan.cuts).toBe(750);
|
||||
expect(plan.coveredArea).toBe(4500 * 95000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ function expectCompleteSkirting(room: Room) {
|
|||
const plan = planSkirting([room], settings, 1);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
const polygons = plan.pieces.map((piece) =>
|
||||
skirtingPolygon(piece, settings.thickness, signedArea(geometry.vertices)),
|
||||
skirtingPolygon(piece, signedArea(geometry.vertices)),
|
||||
);
|
||||
const inner = insetPolygon(geometry.vertices, settings.thickness);
|
||||
// Exact area of the mitred perimeter band; unlike clipping, this does not
|
||||
|
|
@ -57,12 +57,12 @@ function expectCompleteSkirting(room: Room) {
|
|||
return sum + Math.hypot(next.x - point.x, next.y - point.y) / 1000;
|
||||
}, 0);
|
||||
const piece = plan.pieces[i];
|
||||
const installed = piece.installedTemplate!;
|
||||
const installed = piece.installedTemplate;
|
||||
expect(Math.hypot(polygon[0].x - piece.start.x, polygon[0].y - piece.start.y)).toBeCloseTo(
|
||||
Math.abs(installed[0].x - piece.template![0].x),
|
||||
Math.abs(installed[0].x - piece.template[0].x),
|
||||
);
|
||||
expect(Math.hypot(polygon[1].x - piece.end.x, polygon[1].y - piece.end.y)).toBeCloseTo(
|
||||
Math.abs(installed[1].x - piece.template![1].x),
|
||||
Math.abs(installed[1].x - piece.template[1].x),
|
||||
);
|
||||
expect(
|
||||
Math.abs(overlap(polygon, geometry.vertices) - Math.abs(signedArea(polygon))),
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ describe('skirting cutting plan', () => {
|
|||
const pieces = plan.pieces.filter((piece) => piece.wallIndex === wall);
|
||||
expect(pieces[0].leftType).toBe(wall === 0 ? 'internal' : 'cope');
|
||||
expect(pieces.at(-1)?.rightType).toBe(wall === 3 ? 'cope' : 'internal');
|
||||
expect(pieces.every((piece) => piece.visibleLength! >= settings.minLength - 1e-6)).toBe(true);
|
||||
expect(pieces.reduce((sum, piece) => sum + piece.visibleLength!, 0)).toBeCloseTo(
|
||||
expect(pieces.every((piece) => piece.visibleLength >= settings.minLength - 1e-6)).toBe(true);
|
||||
expect(pieces.reduce((sum, piece) => sum + piece.visibleLength, 0)).toBeCloseTo(
|
||||
[4000, 3000, 4000, 3000][wall] - 36,
|
||||
);
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ describe('skirting cutting plan', () => {
|
|||
expect(front.leftType).toBe('external');
|
||||
expect(front.leftBevel).toBeCloseTo(-45);
|
||||
expect(front.rightBevel).toBeCloseTo(45);
|
||||
expect(front.template?.map((point) => Math.round(point.x))).toEqual([18, 1018, 1036, 0]);
|
||||
expect(front.template.map((point) => Math.round(point.x))).toEqual([18, 1018, 1036, 0]);
|
||||
const reversed = { ...room, walls: room.walls.map((wall) => ({ ...wall, turn: -wall.turn })) };
|
||||
const reversePlan = planSkirting([reversed], { ...settings, stockLength: 6000 }, 5);
|
||||
const reverseFront = reversePlan.pieces.find((piece) => piece.wallIndex === 4)!;
|
||||
|
|
@ -265,7 +265,7 @@ describe('skirting cutting plan', () => {
|
|||
for (const piece of plan.pieces) {
|
||||
expect(piece.length).toBeLessThanOrEqual(340 + 1e-6);
|
||||
expect(piece.backLength).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||
expect(piece.template![2].x - piece.template![3].x).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||
expect(piece.template[2].x - piece.template[3].x).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||
expect(piece.visibleLength).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||
}
|
||||
for (let wallIndex = 0; wallIndex < 3; wallIndex++) {
|
||||
|
|
@ -297,7 +297,7 @@ describe('skirting cutting plan', () => {
|
|||
expect(piece.rightMitre).toBeCloseTo(piece.wallIndex === 2 ? 60 : 30);
|
||||
expect(piece.leftType).toBe(piece.wallIndex === 0 ? 'internal' : 'cope');
|
||||
expect(piece.visibleLength).toBeCloseTo(3000 - 36 * Math.tan(Math.PI / 3));
|
||||
expect(piece.installedTemplate![1].x - piece.installedTemplate![0].x).toBeGreaterThan(
|
||||
expect(piece.installedTemplate[1].x - piece.installedTemplate[0].x).toBeGreaterThan(
|
||||
settings.minLength,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,81 +1,61 @@
|
|||
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';
|
||||
import { PERSISTENCE_LIMITS, PROJECT_LIMITS } from '../src/lib/limits';
|
||||
import { decodeState, encodeState } 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);
|
||||
function expectInvalid(link: string, message?: RegExp) {
|
||||
const result = decodeState(link);
|
||||
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);
|
||||
for (const link of ['', '#', '?']) {
|
||||
const first = decodeState(link);
|
||||
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');
|
||||
expect(decodeState(link).state.project.rooms[0].name).toBe('Living room');
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips all default inputs exactly and produces the same link every time', () => {
|
||||
it('round-trips current inputs exactly and produces a stable readable link', () => {
|
||||
const state = defaultState();
|
||||
const hash = encodeState(state);
|
||||
expect(hash).toMatch(/^\?version=1&name=/);
|
||||
const params = new URLSearchParams(hash);
|
||||
const query = encodeState(state);
|
||||
expect(query).toMatch(/^\?version=2&name=/);
|
||||
const params = new URLSearchParams(query);
|
||||
expect(params.get('flooring.length')).toBe('1200');
|
||||
expect(params.get('flooring.optimisation')).toBe('cuts');
|
||||
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);
|
||||
expect(query).not.toContain('#plan=');
|
||||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
expect(encodeState(decodeState(query).state)).toBe(query);
|
||||
expect(encodeState(structuredClone(state))).toBe(query);
|
||||
});
|
||||
|
||||
it('round-trips Unicode, multiple rooms, doors, materials and view selections', () => {
|
||||
it('round-trips Unicode, rooms, doors, materials, progress 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.flooring.optimisation = 'random';
|
||||
state.project.skirting.stockLength = 3600;
|
||||
state.view = { ...state.view, roomId: 'room-2', selectedPiece: 'A12' };
|
||||
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
|
||||
state.view = {
|
||||
roomId: 'room-2',
|
||||
selectedPiece: 'A12',
|
||||
search: 'A12',
|
||||
collapsedSections: ['materials'],
|
||||
material: 'skirting',
|
||||
};
|
||||
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', () => {
|
||||
it('canonicalizes property order and discards unknown derived fields', () => {
|
||||
const state = defaultState();
|
||||
const reordered = {
|
||||
view: { ...state.view, derived: 6 },
|
||||
|
|
@ -83,10 +63,13 @@ describe('URL state', () => {
|
|||
cache: [],
|
||||
};
|
||||
expect(encodeState(reordered)).toBe(encodeState(state));
|
||||
expect(decodeState(rawLink(reordered))).toEqual({ state, error: null });
|
||||
const params = new URLSearchParams(encodeState(state));
|
||||
params.set('boardCount', '500');
|
||||
params.set('room.1.polygon', 'derived');
|
||||
expect(decodeState(`?${params}`)).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('preserves finite but impossible measurements for domain-level feedback', () => {
|
||||
it('preserves finite domain-invalid measurements for planner feedback', () => {
|
||||
const state = defaultState();
|
||||
state.project.rooms[0].walls[0].length = -1;
|
||||
state.project.rooms[0].walls[0].turn = 999;
|
||||
|
|
@ -98,7 +81,7 @@ describe('URL state', () => {
|
|||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('allows an unfinished room and invalid board length without inventing inputs', () => {
|
||||
it('allows unfinished current projects without inventing inputs', () => {
|
||||
const state = defaultState();
|
||||
state.project.rooms[0].walls = [];
|
||||
state.project.skirting.stockLength = 0;
|
||||
|
|
@ -106,151 +89,61 @@ describe('URL state', () => {
|
|||
});
|
||||
|
||||
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));
|
||||
const params = new URLSearchParams(encodeState(defaultState()));
|
||||
params.set('selectedRoom', 'deleted-room');
|
||||
const result = decodeState(`?${params}`);
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.state.view.roomId).toBe(state.project.rooms[0].id);
|
||||
expect(result.state.view.roomId).toBe(result.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(['#plan=old', '#other=value', 'version=2'])('rejects non-query links %s', (link) => {
|
||||
expectInvalid(link, /starting with \?/);
|
||||
});
|
||||
|
||||
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.each(['?version=1', '?version=3'])('rejects unsupported schema link %s', (link) => {
|
||||
expectInvalid(link, /version is unsupported/);
|
||||
});
|
||||
|
||||
it('rejects a valid compression payload containing invalid JSON', () => {
|
||||
expectInvalid(
|
||||
`#plan=${LZString.compressToEncodedURIComponent('{broken')}`,
|
||||
/project data is damaged/,
|
||||
);
|
||||
it('requires every field in the current in-memory contract', () => {
|
||||
const missingOptimisation = defaultState();
|
||||
delete (
|
||||
missingOptimisation.project.flooring as Partial<typeof missingOptimisation.project.flooring>
|
||||
).optimisation;
|
||||
expect(() => encodeState(missingOptimisation)).toThrow(/optimisation.*unsupported/i);
|
||||
|
||||
const missingStockLength = defaultState();
|
||||
delete (
|
||||
missingStockLength.project.skirting as Partial<typeof missingStockLength.project.skirting>
|
||||
).stockLength;
|
||||
expect(() => encodeState(missingStockLength)).toThrow(/finite number/);
|
||||
});
|
||||
|
||||
it('rejects unsupported schema versions', () => {
|
||||
it('rejects incorrectly typed and nonfinite measurements when reading or writing', () => {
|
||||
for (const query of [
|
||||
'?flooring.length=NaN',
|
||||
'?flooring.length=Infinity',
|
||||
'?flooring.length=1e999',
|
||||
'?flooring.length=',
|
||||
'?flooring.length=true',
|
||||
'?flooring.length=0x10',
|
||||
])
|
||||
expectInvalid(query, /finite number/);
|
||||
|
||||
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/);
|
||||
state.project.flooring.length = '1200' as never;
|
||||
expect(() => encodeState(state)).toThrow(/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.each(['?flooring.pattern=diagonal', '?flooring.optimisation=quick', '?room.1.direction=45'])(
|
||||
'rejects an invalid current enum in %s',
|
||||
(query) => expectInvalid(query),
|
||||
);
|
||||
|
||||
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', () => {
|
||||
it('uses individually editable fields and canonical parameter order', () => {
|
||||
const state = defaultState();
|
||||
const params = new URLSearchParams(encodeState(state));
|
||||
params.set('flooring.length', '1400');
|
||||
|
|
@ -270,7 +163,7 @@ describe('URL state', () => {
|
|||
expect(encodeState(decodeState(`?${reversed}`).state)).toBe(encodeState(result.state));
|
||||
});
|
||||
|
||||
it('accepts a small hand-written query and uses defaults for omitted material fields', () => {
|
||||
it('accepts a hand-written partial query with current defaults', () => {
|
||||
const result = decodeState('?flooring.length=1400&flooring.pattern=random&selectedCut=F2');
|
||||
const state = defaultState();
|
||||
state.project.flooring.length = 1400;
|
||||
|
|
@ -279,7 +172,7 @@ describe('URL state', () => {
|
|||
expect(result).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('can describe an unfinished room without storing array counts or generated plans', () => {
|
||||
it('can describe an unfinished room without array counts or generated plans', () => {
|
||||
const result = decodeState(
|
||||
'?room.1.name=Office&room.1.wall.1.length=4000&room.1.wall.1.turn=-90',
|
||||
);
|
||||
|
|
@ -297,336 +190,76 @@ describe('URL state', () => {
|
|||
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', () => {
|
||||
it('rejects duplicate fields and the retired stock-list field', () => {
|
||||
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/);
|
||||
expectInvalid('?skirting.stock=2400', /retired skirting stock-list/);
|
||||
});
|
||||
|
||||
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(
|
||||
'?flooring.optimisation=cuts&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', () => {
|
||||
it('rejects sparse, zero, malformed and excessive 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(
|
||||
'?flooring.optimisation=cuts&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(`?room.${PROJECT_LIMITS.rooms + 1}.id=excess`, /at most 20/);
|
||||
expectInvalid(`?room.1.wall.${PROJECT_LIMITS.wallsPerRoom + 1}.length=4000`, /at most 100/);
|
||||
expectInvalid(
|
||||
'?collapsed=cuts&collapsed=materials&collapsed=measurements&collapsed=cuts',
|
||||
/at most 3/,
|
||||
`?room.1.wall.1.length=4000&room.1.wall.1.door.${PROJECT_LIMITS.doorsPerWall + 1}.width=500`,
|
||||
/at most 30/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects impossible collections and oversized strings before encoding', () => {
|
||||
const empty = defaultState();
|
||||
empty.project.rooms = [];
|
||||
expect(() => encodeState(empty)).toThrow(/at least one room/);
|
||||
|
||||
const duplicate = defaultState();
|
||||
duplicate.project.rooms.push(rectangle());
|
||||
expect(() => encodeState(duplicate)).toThrow(/duplicate identifiers/);
|
||||
|
||||
const rooms = defaultState();
|
||||
rooms.project.rooms = Array.from({ length: PROJECT_LIMITS.rooms + 1 }, (_, index) =>
|
||||
rectangle(`room-${index}`),
|
||||
);
|
||||
expect(() => encodeState(rooms)).toThrow(/at most 20/);
|
||||
|
||||
const walls = defaultState();
|
||||
walls.project.rooms[0].walls = Array.from(
|
||||
{ length: PROJECT_LIMITS.wallsPerRoom + 1 },
|
||||
(_, index) => ({ id: `wall-${index}`, length: 1, turn: 90, doors: [] }),
|
||||
);
|
||||
expect(() => encodeState(walls)).toThrow(/at most 100/);
|
||||
|
||||
const doors = defaultState();
|
||||
doors.project.rooms[0].walls[0].doors = Array.from(
|
||||
{ length: PROJECT_LIMITS.doorsPerWall + 1 },
|
||||
(_, index) => ({ id: `door-${index}`, offset: index * 10, width: 5 }),
|
||||
);
|
||||
expect(() => encodeState(doors)).toThrow(/at most 30/);
|
||||
|
||||
const name = defaultState();
|
||||
name.project.name = 'a'.repeat(PROJECT_LIMITS.nameLength + 1);
|
||||
expect(() => encodeState(name)).toThrow(/at most 200 characters/);
|
||||
});
|
||||
|
||||
it('bounds progress, search and total query length', () => {
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = Array.from(
|
||||
{ length: URL_LIMITS.completedBoards + 1 },
|
||||
{ length: PERSISTENCE_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/,
|
||||
`?search=${'a'.repeat(PERSISTENCE_LIMITS.searchLength + 1)}`,
|
||||
/at most 200 characters/,
|
||||
);
|
||||
expectInvalid(`?name=${'x'.repeat(PERSISTENCE_LIMITS.queryLength)}`, /link is too large/);
|
||||
});
|
||||
|
||||
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.each(['cuts', 'random'] as const)(
|
||||
'saves the %s optimisation and current completion marks',
|
||||
(optimisation) => {
|
||||
const state = defaultState();
|
||||
state.project.flooring.optimisation = optimisation;
|
||||
state.project.completedBoards = ['floor:B4'];
|
||||
const query = encodeState(state);
|
||||
expect(query).toContain(`flooring.optimisation=${optimisation}`);
|
||||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
},
|
||||
);
|
||||
|
||||
it('migrates the previous optimiser without carrying completion onto a different allocation', () => {
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
|
||||
const query = new URLSearchParams(encodeState(state));
|
||||
query.delete('flooring.optimisation');
|
||||
const old = {
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
flooring: { ...state.project.flooring, optimisation: undefined },
|
||||
},
|
||||
};
|
||||
for (const link of [`?${query}`, rawLink(old)]) {
|
||||
const migrated = decodeState(link);
|
||||
expect(migrated.error).toBeNull();
|
||||
expect(migrated.state.project.flooring.optimisation).toBe('cuts');
|
||||
expect(migrated.state.project.completedBoards).toEqual([]);
|
||||
migrated.state.project.completedBoards = ['floor:B1'];
|
||||
expect(decodeState(encodeState(migrated.state))).toEqual(migrated);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([null, 42, true, {}, 'waste'])('rejects unsupported optimisation %j', (optimisation) => {
|
||||
const state = defaultState();
|
||||
expectInvalid(
|
||||
rawLink({
|
||||
...state,
|
||||
project: { ...state.project, flooring: { ...state.project.flooring, optimisation } },
|
||||
}),
|
||||
/Flooring optimisation/,
|
||||
);
|
||||
});
|
||||
|
||||
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/,
|
||||
);
|
||||
it('rejects damaged percent encoding', () => {
|
||||
expectInvalid('?name=%', /invalid URL encoding/);
|
||||
expectInvalid('?name=%C0%AF', /invalid URL encoding/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue