wip
This commit is contained in:
commit
d8f2407ddf
44 changed files with 10377 additions and 0 deletions
119
tests/cut-list.test.ts
Normal file
119
tests/cut-list.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultProject } from '../src/lib/defaults';
|
||||
import { flooringCuts, groupFlooringCuts } from '../src/lib/cut-list';
|
||||
import { planFlooring } from '../src/lib/flooring';
|
||||
import type { FlooringPiece, FlooringPlan } from '../src/lib/types';
|
||||
|
||||
function piece(id: string, changes: Partial<FlooringPiece> = {}): FlooringPiece {
|
||||
return {
|
||||
id,
|
||||
roomId: 'room-1',
|
||||
row: 1,
|
||||
column: 1,
|
||||
polygon: [],
|
||||
length: 600,
|
||||
width: 190,
|
||||
stockId: 'B1',
|
||||
reused: false,
|
||||
cut: true,
|
||||
shaped: false,
|
||||
template: [],
|
||||
factoryLeft: true,
|
||||
factoryRight: false,
|
||||
ripEdge: null,
|
||||
...changes,
|
||||
};
|
||||
}
|
||||
function plan(pieces: FlooringPiece[]): FlooringPlan {
|
||||
return {
|
||||
pieces,
|
||||
rows: [],
|
||||
stocks: [],
|
||||
boardCount: 0,
|
||||
cuts: 0,
|
||||
wasteArea: 0,
|
||||
wastePercent: 0,
|
||||
reusedCount: 0,
|
||||
coveredArea: 0,
|
||||
issues: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('flooring cut batches', () => {
|
||||
it('combines matching cuts across rows and stocks, preserving every placement exactly once', () => {
|
||||
const pieces = [
|
||||
piece('F1'),
|
||||
piece('F2', { row: 4, stockId: 'B4', reused: true }),
|
||||
piece('F3', { cut: false, length: 1200 }),
|
||||
piece('F4', { cut: false, reused: true }),
|
||||
piece('F5', { roomId: 'room-2' }),
|
||||
];
|
||||
const source = plan(pieces),
|
||||
before = structuredClone(source);
|
||||
const groups = groupFlooringCuts(source);
|
||||
expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F1', 'F2'], ['F4'], ['F5']]);
|
||||
expect(
|
||||
groups
|
||||
.flat()
|
||||
.map((p) => p.id)
|
||||
.sort(),
|
||||
).toEqual(
|
||||
flooringCuts(source)
|
||||
.map((p) => p.id)
|
||||
.sort(),
|
||||
);
|
||||
expect(source).toEqual(before);
|
||||
expect(groupFlooringCuts(source)).toEqual(groups);
|
||||
});
|
||||
|
||||
it('keeps different factory ends, rip edges and thousandth-millimetre dimensions separate', () => {
|
||||
const groups = groupFlooringCuts(
|
||||
plan([
|
||||
piece('F1'),
|
||||
piece('F2', { factoryLeft: false, factoryRight: true }),
|
||||
piece('F3', { ripEdge: 'top' }),
|
||||
piece('F4', { ripEdge: 'bottom' }),
|
||||
piece('F5', { length: 600.001 }),
|
||||
piece('F6', { width: 190.001 }),
|
||||
]),
|
||||
);
|
||||
expect(groups).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('groups shapes only when their complete measured templates match', () => {
|
||||
const template = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 600, y: 0 },
|
||||
{ x: 600, y: 190 },
|
||||
{ x: 0, y: 100 },
|
||||
];
|
||||
const groups = groupFlooringCuts(
|
||||
plan([
|
||||
piece('F1', { shaped: true, template }),
|
||||
piece('F2', { shaped: true, template: structuredClone(template) }),
|
||||
piece('F3', { shaped: true, template: [...template.slice(0, 3), { x: 0, y: 100.001 }] }),
|
||||
piece('F4'),
|
||||
]),
|
||||
);
|
||||
expect(groups.map((group) => group.map((p) => p.id))).toEqual([['F1', 'F2'], ['F3'], ['F4']]);
|
||||
});
|
||||
|
||||
it('reduces the default room to a few batches while keeping all cut and offcut codes', () => {
|
||||
const project = defaultProject();
|
||||
const floor = planFlooring(project.rooms, project.flooring, project.tolerance);
|
||||
const groups = groupFlooringCuts(floor);
|
||||
expect(groups.length).toBeLessThanOrEqual(7);
|
||||
expect(groups.length).toBeLessThan(flooringCuts(floor).length / 2);
|
||||
expect(
|
||||
groups
|
||||
.flat()
|
||||
.map((p) => p.id)
|
||||
.sort(),
|
||||
).toEqual(
|
||||
flooringCuts(floor)
|
||||
.map((p) => p.id)
|
||||
.sort(),
|
||||
);
|
||||
expect(new Set(groups.flat().map((p) => p.id)).size).toBe(flooringCuts(floor).length);
|
||||
});
|
||||
});
|
||||
124
tests/cut-preview.test.ts
Normal file
124
tests/cut-preview.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview';
|
||||
import { exactMm, flooringTemplate, skirtingEndBoxes } from '../src/lib/cut-preview';
|
||||
import type { FlooringPiece, Point, SkirtingPiece } from '../src/lib/types';
|
||||
|
||||
const floor: FlooringPiece = {
|
||||
id: 'F1',
|
||||
roomId: 'room-1',
|
||||
row: 1,
|
||||
column: 1,
|
||||
polygon: [],
|
||||
length: 600.001,
|
||||
width: 190,
|
||||
stockId: 'B1',
|
||||
reused: false,
|
||||
cut: true,
|
||||
shaped: false,
|
||||
template: [],
|
||||
};
|
||||
const template: Point[] = [
|
||||
{ x: 18, y: 0 },
|
||||
{ x: 1182, y: 0 },
|
||||
{ x: 1200, y: 18 },
|
||||
{ x: 0, y: 18 },
|
||||
];
|
||||
const skirt: SkirtingPiece = {
|
||||
id: 'A1',
|
||||
roomId: 'room-1',
|
||||
wallId: 'w1',
|
||||
wallIndex: 0,
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 1164, y: 0 },
|
||||
length: 1200,
|
||||
backLength: 1164,
|
||||
leftMitre: 45,
|
||||
rightMitre: 45,
|
||||
leftType: 'external',
|
||||
rightType: 'external',
|
||||
stockId: 'S1',
|
||||
template,
|
||||
};
|
||||
|
||||
describe('cut shape previews', () => {
|
||||
it('draws rectangular floor cuts too, keeping their precise measured dimensions', () => {
|
||||
const points = flooringTemplate(floor);
|
||||
expect(points).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 600.001, y: 0 },
|
||||
{ x: 600.001, y: 190 },
|
||||
{ x: 0, y: 190 },
|
||||
]);
|
||||
const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece: floor }));
|
||||
expect(markup).toContain('aria-label="Flooring shape F1"');
|
||||
expect(markup).toContain('preserveAspectRatio="xMidYMid meet"');
|
||||
expect(markup).toContain('Length 600.001 mm × width 190 mm');
|
||||
expect(exactMm(600.001)).toBe('600.001');
|
||||
});
|
||||
|
||||
it('keeps skirting endpoints in original coordinates and gives both ends equal square windows', () => {
|
||||
const original = structuredClone(template);
|
||||
const boxes = skirtingEndBoxes(template, 18);
|
||||
expect(boxes.left.width).toBe(boxes.left.height);
|
||||
expect(boxes.right.width).toBe(boxes.right.height);
|
||||
expect(boxes.left.width).toBe(boxes.right.width);
|
||||
// Equal x/y scales preserve the measured 18-by-18 mm, 45-degree cut.
|
||||
const scaleX = 120 / boxes.left.width,
|
||||
scaleY = 120 / boxes.left.height;
|
||||
const angle =
|
||||
(Math.atan2(
|
||||
Math.abs(template[3].y - template[0].y) * scaleY,
|
||||
Math.abs(template[3].x - template[0].x) * scaleX,
|
||||
) *
|
||||
180) /
|
||||
Math.PI;
|
||||
expect(angle).toBeCloseTo(45, 8);
|
||||
expect(template).toEqual(original);
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(SkirtingPreview, { piece: skirt, thickness: 18 }),
|
||||
);
|
||||
expect(markup).toContain('points="18,0 1182,0 1200,18 0,18"');
|
||||
expect(markup.match(/preserveAspectRatio="xMidYMid meet"/g)).toHaveLength(3);
|
||||
expect(markup).toContain('Full length 1,200 mm × thickness 18 mm');
|
||||
expect(markup).not.toContain('exaggerat');
|
||||
});
|
||||
|
||||
it('keeps the complete end cut visible even at shallow mitre angles', () => {
|
||||
const shallow = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 2000, y: 0 },
|
||||
{ x: 2100, y: 18 },
|
||||
{ x: 1000, y: 18 },
|
||||
];
|
||||
const boxes = skirtingEndBoxes(shallow, 18);
|
||||
for (const [name, indices] of [
|
||||
['left', [0, 3]],
|
||||
['right', [1, 2]],
|
||||
] as const) {
|
||||
const box = boxes[name];
|
||||
for (const index of indices) {
|
||||
expect(shallow[index].x).toBeGreaterThan(box.x);
|
||||
expect(shallow[index].x).toBeLessThan(box.x + box.width);
|
||||
expect(shallow[index].y).toBeGreaterThan(box.y);
|
||||
expect(shallow[index].y).toBeLessThan(box.y + box.height);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('makes thumbnails decorative while retaining true-scale SVGs and visible end breaks', () => {
|
||||
const floorMarkup = renderToStaticMarkup(
|
||||
createElement(FlooringPreview, { piece: floor, compact: true }),
|
||||
);
|
||||
const skirtMarkup = renderToStaticMarkup(
|
||||
createElement(SkirtingPreview, { piece: skirt, thickness: 18, compact: true }),
|
||||
);
|
||||
expect(floorMarkup).toContain('aria-hidden="true"');
|
||||
expect(skirtMarkup.match(/aria-hidden="true"/g)).toHaveLength(3);
|
||||
expect(skirtMarkup).toContain('>//</span>');
|
||||
expect(skirtMarkup.match(/preserveAspectRatio="xMidYMid meet"/g)).toHaveLength(2);
|
||||
expect(floorMarkup).not.toContain('<figure');
|
||||
expect(skirtMarkup).not.toContain('<figure');
|
||||
});
|
||||
});
|
||||
21
tests/dimension-labels.test.ts
Normal file
21
tests/dimension-labels.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { fireplace } from '../src/lib/defaults';
|
||||
import { buildGeometry } from '../src/lib/geometry';
|
||||
import { dimensionLabels } from '../src/lib/dimension-labels';
|
||||
describe('wall dimensions', () => {
|
||||
it('keeps fireplace recess measurements apart without changing measured lengths', () => {
|
||||
const geometry = buildGeometry(fireplace('room-1', 'Fireplace'), 10);
|
||||
const labels = dimensionLabels(geometry.edges, 4800, 1);
|
||||
expect(labels).toHaveLength(8);
|
||||
expect(labels[3].text).toBe('W4 · 450');
|
||||
for (let i = 0; i < labels.length; i++)
|
||||
for (let j = i + 1; j < labels.length; j++) {
|
||||
const a = labels[i].box,
|
||||
b = labels[j].box;
|
||||
expect(a.minX >= b.maxX || a.maxX <= b.minX || a.minY >= b.maxY || a.maxY <= b.minY).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
expect(dimensionLabels(geometry.edges, 4800, 1)).toEqual(labels);
|
||||
});
|
||||
});
|
||||
181
tests/export.test.ts
Normal file
181
tests/export.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultProject } from '../src/lib/defaults';
|
||||
import { cutListCsv } from '../src/lib/export';
|
||||
import { flooringCuts, isSkirtingCut, uncutFlooring, uncutSkirting } from '../src/lib/cut-list';
|
||||
import type { FlooringPlan, SkirtingPlan } from '../src/lib/types';
|
||||
|
||||
const floor: FlooringPlan = {
|
||||
pieces: [
|
||||
{
|
||||
id: 'F1',
|
||||
roomId: 'room-1',
|
||||
row: 1,
|
||||
column: 1,
|
||||
polygon: [],
|
||||
length: 450,
|
||||
width: 190,
|
||||
stockId: 'B1',
|
||||
reused: true,
|
||||
cut: true,
|
||||
shaped: true,
|
||||
template: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 450, y: 0 },
|
||||
{ x: 450, y: 190 },
|
||||
{ x: 0, y: 100 },
|
||||
],
|
||||
},
|
||||
],
|
||||
rows: [],
|
||||
stocks: [{ id: 'B1', pieceIds: ['F1'], remaining: 747 }],
|
||||
boardCount: 1,
|
||||
cuts: 2,
|
||||
wasteArea: 0,
|
||||
wastePercent: 0,
|
||||
reusedCount: 1,
|
||||
coveredArea: 65250,
|
||||
issues: [{ severity: 'warning', message: 'Check the shaped edge.' }],
|
||||
};
|
||||
const skirt: SkirtingPlan = {
|
||||
pieces: [
|
||||
{
|
||||
id: 'A1',
|
||||
roomId: 'room-1',
|
||||
wallId: 'room-1-w1',
|
||||
wallIndex: 0,
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 1500, y: 0 },
|
||||
length: 1518,
|
||||
backLength: 1500,
|
||||
leftMitre: 0,
|
||||
rightMitre: 45,
|
||||
leftType: 'square',
|
||||
rightType: 'external',
|
||||
stockId: 'S1',
|
||||
},
|
||||
],
|
||||
stocks: [{ id: 'S1', length: 2400, used: 1521, waste: 879, pieceIds: ['A1'] }],
|
||||
stockCount: 1,
|
||||
cuts: 1,
|
||||
waste: 879,
|
||||
issues: [],
|
||||
};
|
||||
|
||||
describe('cut list CSV', () => {
|
||||
it('exports measured cuts, shape coordinates, mitres, allocations and purchase counts', () => {
|
||||
const csv = cutListCsv(defaultProject(), floor, skirt);
|
||||
expect(csv.startsWith('\uFEFF"Material"')).toBe(true);
|
||||
expect(csv).toContain('"Flooring","Living room","F1","B1","450","190"');
|
||||
expect(csv).toContain('Reused offcut; Shaped cut');
|
||||
expect(csv).toContain('0.0:0.0 | 450.0:0.0 | 450.0:190.0 | 0.0:100.0');
|
||||
expect(csv).toContain('"Skirting","Living room","A1","S1","1518","18","0","45"');
|
||||
expect(csv).toContain('Back length 1500.0 mm; left square; right external');
|
||||
expect(csv).toContain('Remaining length 747.0 mm');
|
||||
expect(csv).toContain('Waste including kerf 879.0 mm');
|
||||
expect(csv).toContain('Boards to buy; 1 installed pieces; 2 saw cuts');
|
||||
expect(csv).not.toContain('Check the shaped edge.');
|
||||
expect(csv.split('\r\n')).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('counts uncut full boards and lengths without exporting their codes or source allocations', () => {
|
||||
const project = defaultProject();
|
||||
const wholeFloor = {
|
||||
...floor.pieces[0],
|
||||
id: 'FULL-F',
|
||||
stockId: 'FULL-B',
|
||||
length: 1200,
|
||||
width: 190,
|
||||
cut: false,
|
||||
shaped: false,
|
||||
reused: false,
|
||||
};
|
||||
const readyOffcut = {
|
||||
...floor.pieces[0],
|
||||
id: 'READY-F',
|
||||
length: 747,
|
||||
cut: false,
|
||||
shaped: false,
|
||||
};
|
||||
const floorPlan: FlooringPlan = {
|
||||
...floor,
|
||||
boardCount: 2,
|
||||
pieces: [...floor.pieces, wholeFloor, readyOffcut],
|
||||
stocks: [
|
||||
{ ...floor.stocks[0], pieceIds: ['F1', 'READY-F'], remaining: 0 },
|
||||
{ id: 'FULL-B', pieceIds: ['FULL-F'], remaining: 0 },
|
||||
],
|
||||
};
|
||||
const wholeSkirt = {
|
||||
...skirt.pieces[0],
|
||||
id: 'FULL-A',
|
||||
stockId: 'FULL-S',
|
||||
length: 2400,
|
||||
backLength: 2400,
|
||||
leftMitre: 0,
|
||||
rightMitre: 0,
|
||||
leftType: 'square' as const,
|
||||
rightType: 'square' as const,
|
||||
};
|
||||
const skirtPlan: SkirtingPlan = {
|
||||
...skirt,
|
||||
stockCount: 2,
|
||||
pieces: [...skirt.pieces, wholeSkirt],
|
||||
stocks: [
|
||||
...skirt.stocks,
|
||||
{ id: 'FULL-S', length: 2400, used: 2400, waste: 0, pieceIds: ['FULL-A'] },
|
||||
],
|
||||
};
|
||||
const csv = cutListCsv(project, floorPlan, skirtPlan);
|
||||
for (const code of ['FULL-F', 'FULL-B', 'FULL-A', 'FULL-S']) expect(csv).not.toContain(code);
|
||||
expect(csv).toContain('"Flooring uncut boards","","","","1200","190","","","1"');
|
||||
expect(csv).toContain('"Flooring ready offcuts","","","","","","","","1"');
|
||||
expect(csv).toContain('"Skirting uncut lengths","","","","2400","18","","","1"');
|
||||
expect(csv).toContain('"Flooring total","","","","1200","190","","","2"');
|
||||
expect(csv).toContain('"Skirting total","","","","","18","","","2"');
|
||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring",'))).toHaveLength(2);
|
||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting",'))).toHaveLength(1);
|
||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring stock",'))).toHaveLength(1);
|
||||
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting stock",'))).toHaveLength(1);
|
||||
expect(flooringCuts(floorPlan)).toEqual([...floor.pieces, readyOffcut]);
|
||||
expect(csv).toContain('"Flooring","Living room","READY-F","B1","747","190"');
|
||||
expect(csv).toContain('Reused offcut; Use offcut; no new cut');
|
||||
expect(csv).toContain('"Flooring stock","","F1 / READY-F","B1"');
|
||||
expect(uncutFlooring(floorPlan, project.flooring)).toEqual({ wholeBoards: 1, readyOffcuts: 1 });
|
||||
expect(uncutSkirting(skirtPlan)).toEqual([{ length: 2400, count: 1 }]);
|
||||
});
|
||||
|
||||
it('keeps full-length mitred pieces and shortened square skirting in the cut list', () => {
|
||||
const mitred = { ...skirt.pieces[0], length: 2400 };
|
||||
const shortened = { ...skirt.pieces[0], leftMitre: 0, rightMitre: 0 };
|
||||
const uncut = { ...shortened, length: 2400 };
|
||||
expect(isSkirtingCut(mitred, skirt)).toBe(true);
|
||||
expect(isSkirtingCut(shortened, skirt)).toBe(true);
|
||||
expect(isSkirtingCut(uncut, skirt)).toBe(false);
|
||||
expect(isSkirtingCut({ ...uncut, stockId: 'missing' }, skirt)).toBe(true);
|
||||
const csv = cutListCsv(defaultProject(), floor, { ...skirt, pieces: [mitred] });
|
||||
expect(csv).toContain('"Skirting","Living room","A1","S1","2400","18","0","45"');
|
||||
});
|
||||
|
||||
it('quotes commas, double quotes, newlines and Unicode room names', () => {
|
||||
const project = defaultProject();
|
||||
project.rooms[0].name = 'Café 北, "main"\nroom';
|
||||
const csv = cutListCsv(project, floor, skirt);
|
||||
expect(csv).toContain('"Café 北, ""main""\nroom"');
|
||||
});
|
||||
|
||||
it.each(['=SUM(1,1)', '+cmd', '-formula', '@formula'])(
|
||||
'neutralizes spreadsheet formulas in user text %s',
|
||||
(name) => {
|
||||
const project = defaultProject();
|
||||
project.rooms[0].name = name;
|
||||
expect(cutListCsv(project, floor, skirt)).toContain(`"'${name}"`);
|
||||
},
|
||||
);
|
||||
|
||||
it('produces identical output for identical input without mutating the plans', () => {
|
||||
const project = defaultProject();
|
||||
const before = structuredClone({ project, floor, skirt });
|
||||
expect(cutListCsv(project, floor, skirt)).toBe(cutListCsv(project, floor, skirt));
|
||||
expect({ project, floor, skirt }).toEqual(before);
|
||||
});
|
||||
});
|
||||
297
tests/flooring.test.ts
Normal file
297
tests/flooring.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import ClipperLib from 'clipper-lib';
|
||||
import { planFlooring } from '../src/lib/flooring';
|
||||
import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry';
|
||||
import { fireplace, rectangle } from '../src/lib/defaults';
|
||||
import { flooringCuts } from '../src/lib/cut-list';
|
||||
import type { FlooringPlan, FlooringSettings, Point, Room } from '../src/lib/types';
|
||||
|
||||
const settings: FlooringSettings = {
|
||||
length: 1200,
|
||||
width: 200,
|
||||
gap: 10,
|
||||
pattern: 'brick',
|
||||
minOffset: 300,
|
||||
maxOffset: 600,
|
||||
minLength: 200,
|
||||
minWidth: 60,
|
||||
kerf: 3,
|
||||
};
|
||||
function room(width = 4020, height = 2220, id = '1'): Room {
|
||||
const result = rectangle(id, `Room ${id}`, width, height);
|
||||
result.walls.forEach((wall) => {
|
||||
wall.doors = [];
|
||||
});
|
||||
return result;
|
||||
}
|
||||
function errors(plan: FlooringPlan) {
|
||||
return plan.issues.filter((issue) => issue.severity === 'error');
|
||||
}
|
||||
function overlap(a: Point[], b: Point[]): number {
|
||||
const clipper = new ClipperLib.Clipper();
|
||||
const path = (points: Point[]) =>
|
||||
points.map((p) => ({ X: Math.round(p.x * 1000), Y: Math.round(p.y * 1000) }));
|
||||
clipper.AddPath(path(a), ClipperLib.PolyType.ptSubject, true);
|
||||
clipper.AddPath(path(b), ClipperLib.PolyType.ptClip, true);
|
||||
const result: ClipperLib.Paths = [];
|
||||
clipper.Execute(ClipperLib.ClipType.ctIntersection, result);
|
||||
return result.reduce((sum, polygon) => sum + Math.abs(ClipperLib.Clipper.Area(polygon)) / 1e6, 0);
|
||||
}
|
||||
function expectCoverage(plan: FlooringPlan, r: Room, gap: number) {
|
||||
const inset = insetPolygon(buildGeometry(r, 1).vertices, gap);
|
||||
const expectedArea = inset.reduce((sum, p) => sum + Math.abs(signedArea(p)), 0);
|
||||
const pieces = plan.pieces.filter((p) => p.roomId === r.id);
|
||||
// Clipping rounds intersections to 0.001 mm; permit less than 1 mm² area drift.
|
||||
expect(
|
||||
Math.abs(pieces.reduce((sum, p) => sum + Math.abs(signedArea(p.polygon)), 0) - expectedArea),
|
||||
).toBeLessThan(1);
|
||||
for (let i = 0; i < pieces.length; i++) {
|
||||
expect(
|
||||
Math.abs(
|
||||
inset.reduce((sum, p) => sum + overlap(pieces[i].polygon, p), 0) -
|
||||
Math.abs(signedArea(pieces[i].polygon)),
|
||||
),
|
||||
).toBeLessThan(1);
|
||||
for (let j = i + 1; j < pieces.length; j++)
|
||||
expect(overlap(pieces[i].polygon, pieces[j].polygon)).toBeLessThan(0.01);
|
||||
}
|
||||
}
|
||||
function expectStockFits(plan: FlooringPlan, s: FlooringSettings) {
|
||||
const ids = plan.pieces.map((piece) => piece.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(plan.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual([...ids].sort());
|
||||
for (const stock of plan.stocks) {
|
||||
const pieces = stock.pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
|
||||
const used = pieces.reduce((sum, piece) => sum + piece.length, 0);
|
||||
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);
|
||||
}
|
||||
}
|
||||
function expectOffsets(plan: FlooringPlan, s: FlooringSettings) {
|
||||
for (let i = 1; i < plan.rows.length; i++) {
|
||||
const a = plan.rows[i - 1],
|
||||
b = plan.rows[i];
|
||||
if (a.roomId !== b.roomId) continue;
|
||||
const difference = Math.abs(a.offset - b.offset);
|
||||
const distance = Math.min(difference, s.length - difference);
|
||||
expect(distance).toBeGreaterThanOrEqual(s.minOffset - 0.01);
|
||||
expect(distance).toBeLessThanOrEqual(s.maxOffset + 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
describe('laminate planning', () => {
|
||||
it('covers a rectangle exactly, without overlap, and stays inside its expansion gap', () => {
|
||||
const r = room();
|
||||
const plan = planFlooring([r], settings, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(plan.coveredArea).toBe(4000 * 2200);
|
||||
expectCoverage(plan, r, settings.gap);
|
||||
expect(
|
||||
plan.pieces.every((p) => p.length >= settings.minLength && p.width >= settings.minWidth),
|
||||
).toBe(true);
|
||||
expectOffsets(plan, settings);
|
||||
expectStockFits(plan, settings);
|
||||
});
|
||||
|
||||
it('splits a narrow remainder between first and last rows', () => {
|
||||
const plan = planFlooring([room(4020, 2040)], settings, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(plan.rows).toHaveLength(11);
|
||||
expect(plan.rows[0].width).toBe(110);
|
||||
expect(plan.rows.at(-1)?.width).toBe(110);
|
||||
expect(plan.rows.slice(1, -1).every((row) => row.width === 200)).toBe(true);
|
||||
expect(
|
||||
plan.pieces.filter((piece) => piece.row === 1).every((piece) => piece.ripEdge === 'top'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
plan.pieces.filter((piece) => piece.row === 11).every((piece) => piece.ripEdge === 'bottom'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects impossible edge widths instead of ripping factory joints off middle rows', () => {
|
||||
const plan = planFlooring([room(4020, 450)], { ...settings, minWidth: 120 }, 1);
|
||||
expect(plan.boardCount).toBe(0);
|
||||
expect(errors(plan)[0].message).toContain('minimum row width');
|
||||
});
|
||||
|
||||
it('reuses offcuts without double counting stock or ignoring saw kerf', () => {
|
||||
const r = room(4020, 2020);
|
||||
const plan = planFlooring([r], settings, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(plan.reusedCount).toBeGreaterThan(0);
|
||||
expectStockFits(plan, settings);
|
||||
// A 600 mm starter and 600 mm finisher cannot share a 1200 mm board with a 3 mm kerf.
|
||||
for (const stock of plan.stocks) {
|
||||
const lengths = stock.pieceIds.map((id) => plan.pieces.find((p) => p.id === id)!.length);
|
||||
expect(lengths.filter((length) => Math.abs(length - 600) < 0.01).length).toBeLessThan(2);
|
||||
}
|
||||
expect(plan.boardCount).toBeGreaterThanOrEqual(
|
||||
Math.ceil(plan.coveredArea / (settings.length * settings.width)),
|
||||
);
|
||||
expect(plan.wasteArea).toBeCloseTo(
|
||||
plan.boardCount * settings.length * settings.width - plan.coveredArea,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('produces exact concave fireplace templates and keeps the gap at its external corners', () => {
|
||||
const r = fireplace('fire', 'Fireplace room');
|
||||
const plan = planFlooring([r], { ...settings, width: 190 }, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expectCoverage(plan, r, settings.gap);
|
||||
expect(plan.pieces.some((piece) => piece.shaped)).toBe(true);
|
||||
for (const piece of plan.pieces) {
|
||||
expect(
|
||||
piece.template.every(
|
||||
(p) =>
|
||||
p.x >= -0.01 && p.y >= -0.01 && p.x <= piece.length + 0.01 && p.y <= piece.width + 0.01,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
expectStockFits(plan, { ...settings, width: 190 });
|
||||
});
|
||||
|
||||
it('clips an angled wall exactly and provides shaped cut coordinates', () => {
|
||||
const points = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 4000, y: 0 },
|
||||
{ x: 4200, y: 2200 },
|
||||
{ x: 0, y: 2200 },
|
||||
];
|
||||
const headings = points.map(
|
||||
(p, i) =>
|
||||
(Math.atan2(
|
||||
points[(i + 1) % points.length].y - p.y,
|
||||
points[(i + 1) % points.length].x - p.x,
|
||||
) *
|
||||
180) /
|
||||
Math.PI,
|
||||
);
|
||||
const r: Room = {
|
||||
id: 'angled',
|
||||
name: 'Angled room',
|
||||
direction: 0,
|
||||
walls: points.map((p, i) => ({
|
||||
id: `w${i}`,
|
||||
length: Math.hypot(
|
||||
points[(i + 1) % points.length].x - p.x,
|
||||
points[(i + 1) % points.length].y - p.y,
|
||||
),
|
||||
turn: ((headings[(i + 1) % points.length] - headings[i] + 540) % 360) - 180,
|
||||
doors: [],
|
||||
})),
|
||||
};
|
||||
const plan = planFlooring([r], settings, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expectCoverage(plan, r, settings.gap);
|
||||
expect(plan.pieces.filter((piece) => piece.shaped).length).toBeGreaterThan(2);
|
||||
expectStockFits(plan, settings);
|
||||
});
|
||||
|
||||
it('gives identical random plans for identical input and respects both stagger limits', () => {
|
||||
const s = { ...settings, pattern: 'random' as const, minOffset: 250, maxOffset: 450 };
|
||||
const a = planFlooring([room()], s, 1);
|
||||
expect(errors(a)).toEqual([]);
|
||||
expect(a).toEqual(planFlooring([room()], s, 1));
|
||||
expect(new Set(a.rows.map((row) => row.offset)).size).toBeGreaterThan(2);
|
||||
expectOffsets(a, s);
|
||||
expectStockFits(a, s);
|
||||
expectCoverage(a, room(), s.gap);
|
||||
});
|
||||
|
||||
it('rejects impossible brick stagger constraints', () => {
|
||||
const plan = planFlooring([room()], { ...settings, maxOffset: 400 }, 1);
|
||||
expect(plan.boardCount).toBe(0);
|
||||
expect(errors(plan)[0].message).toContain('half-board');
|
||||
});
|
||||
|
||||
it('plans each room independently and can reuse compatible end offcuts across directions', () => {
|
||||
const a = room(1820, 1220, '1');
|
||||
const b = room(1220, 1820, '2');
|
||||
b.direction = 90;
|
||||
const combined = planFlooring([a, b], settings, 1);
|
||||
const separately =
|
||||
planFlooring([a], settings, 1).boardCount + planFlooring([b], settings, 1).boardCount;
|
||||
expect(errors(combined)).toEqual([]);
|
||||
expect(combined.boardCount).toBeLessThanOrEqual(separately);
|
||||
expect(
|
||||
combined.stocks.some(
|
||||
(stock) =>
|
||||
new Set(stock.pieceIds.map((id) => combined.pieces.find((p) => p.id === id)!.roomId))
|
||||
.size > 1,
|
||||
),
|
||||
).toBe(true);
|
||||
expectCoverage(combined, a, settings.gap);
|
||||
expectCoverage(combined, b, settings.gap);
|
||||
expectStockFits(combined, settings);
|
||||
expectOffsets(combined, settings);
|
||||
});
|
||||
|
||||
it('reports an unclosed room and never counts a partial floor', () => {
|
||||
const r = room();
|
||||
r.walls[0].length += 100;
|
||||
const plan = planFlooring([r], settings, 1);
|
||||
expect(plan.boardCount).toBe(0);
|
||||
expect(plan.pieces).toEqual([]);
|
||||
expect(errors(plan)[0].message).toContain('closed outline');
|
||||
});
|
||||
|
||||
it('rejects zero and nonfinite settings without unbounded loops', () => {
|
||||
expect(errors(planFlooring([room()], { ...settings, width: 0 }, 1))).toHaveLength(1);
|
||||
const nonfinite = planFlooring([room()], { ...settings, length: NaN }, 1);
|
||||
expect(errors(nonfinite)).toHaveLength(1);
|
||||
expect(nonfinite.wasteArea).toBe(0);
|
||||
expect(errors(planFlooring([room()], { ...settings, length: 1e308 }, 1))).toHaveLength(1);
|
||||
expect(
|
||||
errors(planFlooring([room()], { ...settings, width: 0.001, minWidth: 0.001 }, 1)),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('numbers cuts and ready offcuts continuously while preserving all row and stock references', () => {
|
||||
const s = { ...settings, pattern: 'random' as const, minOffset: 250, maxOffset: 450 };
|
||||
const plan = planFlooring([room()], s, 1);
|
||||
const cuts = flooringCuts(plan);
|
||||
expect(plan.pieces.length).toBeGreaterThan(cuts.length);
|
||||
expect(cuts.some((piece) => piece.reused && !piece.cut)).toBe(true);
|
||||
expect(cuts.map((piece) => piece.id)).toEqual(cuts.map((_, index) => `F${index + 1}`));
|
||||
expect(
|
||||
plan.pieces
|
||||
.filter((piece) => !piece.cut && !piece.reused)
|
||||
.every((piece) => piece.id.startsWith('FU')),
|
||||
).toBe(true);
|
||||
const ids = plan.pieces.map((piece) => piece.id);
|
||||
expect(plan.rows.flatMap((row) => row.pieceIds).sort()).toEqual([...ids].sort());
|
||||
expectStockFits(plan, s);
|
||||
});
|
||||
|
||||
it('scopes room failures and shape warnings without scoping global material errors', () => {
|
||||
const valid = room(4020, 2220, 'valid');
|
||||
const open = room(4020, 2220, 'open');
|
||||
open.walls[0].length += 100;
|
||||
const shaped = fireplace('shaped', 'Fireplace room');
|
||||
const plan = planFlooring([valid, open, shaped], { ...settings, width: 190 }, 1);
|
||||
expect(plan.pieces.some((piece) => piece.roomId === valid.id)).toBe(true);
|
||||
expect(plan.pieces.some((piece) => piece.roomId === open.id)).toBe(false);
|
||||
expect(errors(plan).map((issue) => issue.roomId)).toEqual(['open']);
|
||||
expect(
|
||||
plan.issues.filter((issue) => issue.severity === 'warning').map((issue) => issue.roomId),
|
||||
).toEqual(['shaped']);
|
||||
expect(errors(planFlooring([valid], { ...settings, width: 0 }, 1))[0].roomId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
173
tests/geometry.test.ts
Normal file
173
tests/geometry.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry';
|
||||
import type { Point, Room } from '../src/lib/types';
|
||||
|
||||
const rectangle = (last = 3000): Room => ({
|
||||
id: 'r1',
|
||||
name: 'Room',
|
||||
direction: 0,
|
||||
walls: [4000, 3000, 4000, last].map((length, index) => ({
|
||||
id: `w${index}`,
|
||||
length,
|
||||
turn: 90,
|
||||
doors: [],
|
||||
})),
|
||||
});
|
||||
|
||||
function roomFromPoints(vertices: Point[]): Room {
|
||||
const headings = vertices.map((point, index) => {
|
||||
const next = vertices[(index + 1) % vertices.length];
|
||||
return (Math.atan2(next.y - point.y, next.x - point.x) * 180) / Math.PI;
|
||||
});
|
||||
return {
|
||||
id: 'shape',
|
||||
name: 'Shape',
|
||||
direction: 0,
|
||||
walls: vertices.map((point, index) => {
|
||||
const next = vertices[(index + 1) % vertices.length];
|
||||
const turn = ((headings[(index + 1) % headings.length] - headings[index] + 540) % 360) - 180;
|
||||
return {
|
||||
id: `w${index}`,
|
||||
length: Math.hypot(next.x - point.x, next.y - point.y),
|
||||
turn,
|
||||
doors: [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('measured room geometry', () => {
|
||||
it('closes a rectangle and ignores the unused final turn', () => {
|
||||
const room = rectangle();
|
||||
room.walls[3].turn = 0;
|
||||
const result = buildGeometry(room, 5);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.vertices).toHaveLength(4);
|
||||
expect(result.area).toBeCloseTo(12_000_000);
|
||||
expect(result.perimeter).toBeCloseTo(14_000);
|
||||
expect(result.finalLengthError).toBeCloseTo(0);
|
||||
expect(result.finalAngleError).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('predicts the next closing length and signed turn for an unfinished room', () => {
|
||||
const room = rectangle();
|
||||
room.walls.pop();
|
||||
const result = buildGeometry(room, 5);
|
||||
expect(result.closed).toBe(false);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.vertices).toHaveLength(4);
|
||||
expect(result.closingLength).toBeCloseTo(3000);
|
||||
expect(result.closingTurn).toBeCloseTo(90);
|
||||
});
|
||||
|
||||
it('reports the measured final wall mismatch, accepting only residuals inside tolerance', () => {
|
||||
const result = buildGeometry(rectangle(3003), 5);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.gap).toBeCloseTo(3);
|
||||
expect(result.finalLengthError).toBeCloseTo(3);
|
||||
expect(result.edges[3].length).toBeCloseTo(3000);
|
||||
expect(
|
||||
result.issues.some(
|
||||
(issue) => issue.severity === 'warning' && issue.message.includes('remeasure'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(buildGeometry(rectangle(3020), 5).valid).toBe(false);
|
||||
expect(buildGeometry(rectangle(3020), 1e308).closed).toBe(false);
|
||||
expect(buildGeometry(rectangle(), 51).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('reports direction mismatch rather than assigning the residual to a bowed wall', () => {
|
||||
const room = rectangle();
|
||||
room.walls[2].turn = 88;
|
||||
const result = buildGeometry(room, 5);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(Math.abs(result.finalAngleError)).toBeCloseTo(2);
|
||||
expect(result.gap).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('supports a concave fireplace and a slanted outer wall', () => {
|
||||
const room = roomFromPoints([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 5000, y: 0 },
|
||||
{ x: 5000, y: 4000 },
|
||||
{ x: 3000, y: 4000 },
|
||||
{ x: 3000, y: 3400 },
|
||||
{ x: 2000, y: 3400 },
|
||||
{ x: 2000, y: 4000 },
|
||||
{ x: 0, y: 3500 },
|
||||
]);
|
||||
const result = buildGeometry(room, 0.1);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.vertices).toHaveLength(8);
|
||||
expect(result.area).toBeCloseTo(18_900_000);
|
||||
const inset = insetPolygon(result.vertices, 10);
|
||||
expect(inset).toHaveLength(1);
|
||||
expect(Math.abs(signedArea(inset[0]))).toBeLessThan(result.area);
|
||||
});
|
||||
|
||||
it('rejects crossing outlines, invalid lengths and impossible turns', () => {
|
||||
const crossed = roomFromPoints([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 4000, y: 0 },
|
||||
{ x: 0, y: 3000 },
|
||||
{ x: 4000, y: 3000 },
|
||||
]);
|
||||
expect(buildGeometry(crossed, 5).issues.some((issue) => issue.message.includes('cross'))).toBe(
|
||||
true,
|
||||
);
|
||||
const room = rectangle();
|
||||
room.walls[0].length = -1;
|
||||
room.walls[1].turn = 180;
|
||||
expect(buildGeometry(room, 5).valid).toBe(false);
|
||||
room.walls[0].length = 1e308;
|
||||
expect(Number.isFinite(buildGeometry(room, 5).area)).toBe(true);
|
||||
});
|
||||
|
||||
it('validates door boundaries and overlapping openings', () => {
|
||||
const room = rectangle();
|
||||
room.walls[0].doors = [
|
||||
{ id: 'd1', offset: 300, width: 900 },
|
||||
{ id: 'd2', offset: 1000, width: 900 },
|
||||
];
|
||||
expect(buildGeometry(room, 5).issues.some((issue) => issue.message.includes('overlap'))).toBe(
|
||||
true,
|
||||
);
|
||||
room.walls[0].doors = [{ id: 'd1', offset: 3500, width: 900 }];
|
||||
expect(buildGeometry(room, 5).valid).toBe(false);
|
||||
room.walls[0].doors = [{ id: 'd1', offset: 0, width: 900 }];
|
||||
expect(buildGeometry(room, 5).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('insets clockwise and anticlockwise rectangles and detects an erased room', () => {
|
||||
const vertices = buildGeometry(rectangle(), 5).vertices;
|
||||
for (const points of [vertices, [...vertices].reverse()]) {
|
||||
const inset = insetPolygon(points, 10);
|
||||
expect(inset).toHaveLength(1);
|
||||
expect(Math.abs(signedArea(inset[0]))).toBeCloseTo(3980 * 2980);
|
||||
}
|
||||
expect(insetPolygon(vertices, 1600)).toEqual([]);
|
||||
expect(insetPolygon(vertices, 1e308)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns separate floor regions when a narrow connecting passage disappears', () => {
|
||||
const inset = insetPolygon(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 2000, y: 0 },
|
||||
{ x: 2000, y: 900 },
|
||||
{ x: 3000, y: 900 },
|
||||
{ x: 3000, y: 0 },
|
||||
{ x: 5000, y: 0 },
|
||||
{ x: 5000, y: 2000 },
|
||||
{ x: 3000, y: 2000 },
|
||||
{ x: 3000, y: 1100 },
|
||||
{ x: 2000, y: 1100 },
|
||||
{ x: 2000, y: 2000 },
|
||||
{ x: 0, y: 2000 },
|
||||
],
|
||||
150,
|
||||
);
|
||||
expect(inset).toHaveLength(2);
|
||||
expect(inset.every((polygon) => signedArea(polygon) > 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
111
tests/skirting-polygon.test.ts
Normal file
111
tests/skirting-polygon.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import ClipperLib from 'clipper-lib';
|
||||
import { fireplace, rectangle } from '../src/lib/defaults';
|
||||
import { buildGeometry, insetPolygon, normalizeAngle, signedArea } from '../src/lib/geometry';
|
||||
import { planSkirting } from '../src/lib/skirting';
|
||||
import { skirtingPolygon } from '../src/lib/skirting-polygon';
|
||||
import type { Point, Room } from '../src/lib/types';
|
||||
|
||||
const settings = { stockLengths: [2400], thickness: 18, minLength: 200, kerf: 3 };
|
||||
|
||||
function overlap(a: Point[], b: Point[]): number {
|
||||
const clipper = new ClipperLib.Clipper();
|
||||
const path = (points: Point[]) =>
|
||||
points.map((point) => ({
|
||||
X: Math.round(point.x * 1000),
|
||||
Y: Math.round(point.y * 1000),
|
||||
}));
|
||||
clipper.AddPath(path(a), ClipperLib.PolyType.ptSubject, true);
|
||||
clipper.AddPath(path(b), ClipperLib.PolyType.ptClip, true);
|
||||
const intersection: ClipperLib.Paths = [];
|
||||
clipper.Execute(ClipperLib.ClipType.ctIntersection, intersection);
|
||||
return intersection.reduce(
|
||||
(sum, polygon) => sum + Math.abs(ClipperLib.Clipper.Area(polygon)) / 1e6,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function expectCompleteSkirting(room: Room) {
|
||||
room.walls.forEach((wall) => {
|
||||
wall.doors = [];
|
||||
});
|
||||
const geometry = buildGeometry(room, 1);
|
||||
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)),
|
||||
);
|
||||
const inner = insetPolygon(geometry.vertices, settings.thickness);
|
||||
// Exact area of the mitred perimeter band; unlike clipping, this does not
|
||||
// round angled intersections to the nearest 0.001 mm.
|
||||
const orientation = Math.sign(signedArea(geometry.vertices));
|
||||
const cornerArea = geometry.edges.reduce((sum, edge, index, edges) => {
|
||||
const turn =
|
||||
normalizeAngle(edges[(index + 1) % edges.length].heading - edge.heading) * orientation;
|
||||
return sum + settings.thickness ** 2 * Math.tan((turn * Math.PI) / 360);
|
||||
}, 0);
|
||||
const expectedArea = geometry.perimeter * settings.thickness - cornerArea;
|
||||
expect(polygons.reduce((sum, polygon) => sum + Math.abs(signedArea(polygon)), 0)).toBeCloseTo(
|
||||
expectedArea,
|
||||
5,
|
||||
);
|
||||
for (let i = 0; i < polygons.length; i++) {
|
||||
const polygon = polygons[i];
|
||||
// Inset and intersection coordinates each round to a 0.001 mm grid.
|
||||
const clippingTolerance = polygon.reduce((sum, point, index) => {
|
||||
const next = polygon[(index + 1) % polygon.length];
|
||||
return sum + Math.hypot(next.x - point.x, next.y - point.y) / 1000;
|
||||
}, 0);
|
||||
expect(polygon[0].x).toBeCloseTo(plan.pieces[i].start.x);
|
||||
expect(polygon[0].y).toBeCloseTo(plan.pieces[i].start.y);
|
||||
expect(polygon[1].x).toBeCloseTo(plan.pieces[i].end.x);
|
||||
expect(polygon[1].y).toBeCloseTo(plan.pieces[i].end.y);
|
||||
expect(
|
||||
Math.abs(overlap(polygon, geometry.vertices) - Math.abs(signedArea(polygon))),
|
||||
).toBeLessThan(clippingTolerance);
|
||||
for (const innerPolygon of inner)
|
||||
expect(overlap(polygon, innerPolygon)).toBeLessThan(clippingTolerance);
|
||||
for (let j = i + 1; j < polygons.length; j++)
|
||||
expect(overlap(polygon, polygons[j])).toBeLessThan(clippingTolerance);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
describe('installed skirting polygons', () => {
|
||||
it.each([1, -1])(
|
||||
'covers a rectangle including uncut stock without overlapping butt joints (orientation %i)',
|
||||
(orientation) => {
|
||||
const room = rectangle();
|
||||
room.walls.forEach((wall) => {
|
||||
wall.turn *= orientation;
|
||||
});
|
||||
const plan = expectCompleteSkirting(room);
|
||||
expect(plan.pieces.some((piece) => piece.id.startsWith('SU'))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1, -1])(
|
||||
'joins fireplace mitres without gaps or overlaps (orientation %i)',
|
||||
(orientation) => {
|
||||
const room = fireplace('fire', 'Fireplace');
|
||||
room.walls.forEach((wall) => {
|
||||
wall.turn *= orientation;
|
||||
});
|
||||
const plan = expectCompleteSkirting(room);
|
||||
expect(
|
||||
plan.pieces.some(
|
||||
(piece) => piece.leftType === 'external' && piece.rightType === 'external',
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('matches the room boundary at angled internal butt joints', () => {
|
||||
expectCompleteSkirting({
|
||||
id: 'triangle',
|
||||
name: 'Triangle',
|
||||
direction: 0,
|
||||
walls: [0, 1, 2].map((index) => ({ id: `w${index}`, length: 3000, turn: 120, doors: [] })),
|
||||
});
|
||||
});
|
||||
});
|
||||
323
tests/skirting.test.ts
Normal file
323
tests/skirting.test.ts
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { planSkirting } from '../src/lib/skirting';
|
||||
import { skirtingCuts } from '../src/lib/cut-list';
|
||||
import type { Room, SkirtingSettings } from '../src/lib/types';
|
||||
|
||||
const settings: SkirtingSettings = { stockLengths: [2400], thickness: 18, minLength: 300, kerf: 3 };
|
||||
const rectangle = (width = 4000, height = 3000, id = 'r1'): Room => ({
|
||||
id,
|
||||
name: id,
|
||||
direction: 0,
|
||||
walls: [width, height, width, height].map((length, index) => ({
|
||||
id: `w${index}`,
|
||||
length,
|
||||
turn: 90,
|
||||
doors: [],
|
||||
})),
|
||||
});
|
||||
|
||||
describe('skirting cutting plan', () => {
|
||||
it('subtracts internal butt thickness and preserves every installed wall run', () => {
|
||||
const plan = planSkirting([rectangle()], settings, 5);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(
|
||||
14_000 - 4 * 18,
|
||||
);
|
||||
expect(plan.pieces.every((piece) => piece.length >= settings.minLength)).toBe(true);
|
||||
expect(skirtingCuts(plan)[0].id).toBe('A1');
|
||||
for (let wall = 0; wall < 4; wall++) {
|
||||
const pieces = plan.pieces.filter((piece) => piece.wallIndex === wall);
|
||||
expect(pieces[0].leftType).toBe('internal');
|
||||
expect(pieces.at(-1)?.rightType).toBe('internal');
|
||||
expect(
|
||||
pieces.every(
|
||||
(piece) => Math.abs(piece.leftMitre) < 1e-6 && Math.abs(piece.rightMitre) < 1e-6,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('removes doors and cuts square at each opening', () => {
|
||||
const room = rectangle();
|
||||
room.walls[0].doors = [{ id: 'd1', offset: 1000, width: 900 }];
|
||||
const plan = planSkirting([room], settings, 5);
|
||||
const pieces = plan.pieces.filter((piece) => piece.wallIndex === 0);
|
||||
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(4000 - 900 - 18);
|
||||
expect(
|
||||
pieces.some((piece) => Math.abs(piece.end.x - 1000) < 1e-6 && piece.rightType === 'square'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
pieces.some((piece) => Math.abs(piece.start.x - 1900) < 1e-6 && piece.leftType === 'square'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
pieces.every((piece) => piece.end.x <= 1000 + 1e-6 || piece.start.x >= 1900 - 1e-6),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not silently produce undersized pieces next to doors', () => {
|
||||
const room = rectangle();
|
||||
room.walls[0].doors = [{ id: 'd1', offset: 200, width: 900 }];
|
||||
const plan = planSkirting([room], settings, 5);
|
||||
expect(plan.pieces).toEqual([]);
|
||||
expect(
|
||||
plan.issues.some((issue) => issue.severity === 'error' && issue.message.includes('minimum')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects crossing angled end cuts even when the back length meets the minimum', () => {
|
||||
const room: Room = {
|
||||
id: 'acute',
|
||||
name: 'Acute corner',
|
||||
direction: 0,
|
||||
walls: [1000, 1000, 2000 * Math.sin((5 * Math.PI) / 180)].map((length, index) => ({
|
||||
id: `w${index}`,
|
||||
length,
|
||||
turn: [170, 95, 95][index],
|
||||
doors: index === 1 ? [{ id: 'd1', offset: 200, width: 300 }] : [],
|
||||
})),
|
||||
};
|
||||
const plan = planSkirting([room], { ...settings, minLength: 50 }, 5);
|
||||
expect(plan.pieces).toEqual([]);
|
||||
expect(
|
||||
plan.issues.some((issue) => issue.message.includes('shorter than the 50 mm minimum')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('extends the long point at external fireplace corners and specifies 45° mitres', () => {
|
||||
const room: Room = {
|
||||
id: 'r1',
|
||||
name: 'Fireplace',
|
||||
direction: 0,
|
||||
walls: [5000, 4000, 2000, 600, 1000, 600, 2000, 4000].map((length, index) => ({
|
||||
id: `w${index}`,
|
||||
length,
|
||||
turn: [90, 90, 90, -90, -90, 90, 90, 90][index],
|
||||
doors: [],
|
||||
})),
|
||||
};
|
||||
const plan = planSkirting([room], { ...settings, stockLengths: [6000] }, 5);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
const front = plan.pieces.find((piece) => piece.wallIndex === 4)!;
|
||||
expect(front.backLength).toBeCloseTo(1000);
|
||||
expect(front.length).toBeCloseTo(1036);
|
||||
expect(front.leftMitre).toBeCloseTo(45);
|
||||
expect(front.rightMitre).toBeCloseTo(45);
|
||||
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]);
|
||||
const reversed = { ...room, walls: room.walls.map((wall) => ({ ...wall, turn: -wall.turn })) };
|
||||
const reversePlan = planSkirting([reversed], { ...settings, stockLengths: [6000] }, 5);
|
||||
const reverseFront = reversePlan.pieces.find((piece) => piece.wallIndex === 4)!;
|
||||
expect(reverseFront.length).toBeCloseTo(1036);
|
||||
expect(reverseFront.leftBevel).toBeCloseTo(-45);
|
||||
expect(reverseFront.rightBevel).toBeCloseTo(45);
|
||||
});
|
||||
|
||||
it('packs across rooms with deterministic unique IDs and accounts for kerf', () => {
|
||||
const rooms = [rectangle(1800, 1800, 'r1'), rectangle(1800, 1800, 'r2')];
|
||||
const options = { ...settings, stockLengths: [1800, 3600], thickness: 0, kerf: 3 };
|
||||
const plan = planSkirting(rooms, options, 5);
|
||||
expect(plan).toEqual(planSkirting(rooms, options, 5));
|
||||
expect(new Set(plan.pieces.map((piece) => piece.id)).size).toBe(plan.pieces.length);
|
||||
expect(plan.pieces.every((piece) => piece.id.startsWith('SU'))).toBe(true);
|
||||
expect(plan.stockCount).toBe(8);
|
||||
expect(plan.cuts).toBe(0);
|
||||
expect(plan.waste).toBeCloseTo(0);
|
||||
const trimmed = planSkirting(
|
||||
[rectangle(1199, 1199)],
|
||||
{ ...settings, stockLengths: [2400], thickness: 0, minLength: 1199 },
|
||||
5,
|
||||
);
|
||||
expect(trimmed.stockCount).toBe(4); // 1199 + 3 + 1199 cannot fit into 2400.
|
||||
expect(trimmed.cuts).toBe(4);
|
||||
for (const stock of trimmed.stocks) expect(stock.used + stock.waste).toBeCloseTo(stock.length);
|
||||
});
|
||||
|
||||
it('allows the final saw pass to extend beyond a short stock tail', () => {
|
||||
const plan = planSkirting(
|
||||
[rectangle(1199, 1199)],
|
||||
{ ...settings, stockLengths: [1200], thickness: 0 },
|
||||
5,
|
||||
);
|
||||
expect(plan.pieces).toHaveLength(4);
|
||||
expect(plan.stockCount).toBe(4);
|
||||
expect(plan.waste).toBeCloseTo(4);
|
||||
expect(plan.cuts).toBe(4);
|
||||
});
|
||||
|
||||
it('looks ahead when choosing from different stock lengths', () => {
|
||||
const plan = planSkirting(
|
||||
[rectangle(1200, 1200)],
|
||||
{ ...settings, stockLengths: [1800, 2400], thickness: 0, kerf: 0 },
|
||||
5,
|
||||
);
|
||||
expect(plan.stockCount).toBe(2);
|
||||
expect(plan.stocks.every((stock) => stock.length === 2400)).toBe(true);
|
||||
expect(plan.waste).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('lets reused tails set wall joints to save a stock length without extra saw cuts', () => {
|
||||
const room = rectangle(4000, 4000);
|
||||
const plan = planSkirting([room], settings, 5);
|
||||
expect(plan).toEqual(planSkirting([room], settings, 5));
|
||||
expect(plan.stockCount).toBe(7); // Fixed joints used eight lengths for this simple square.
|
||||
expect(plan.cuts).toBe(4);
|
||||
expect(plan.waste).toBeCloseTo(7 * 2400 - (16000 - 4 * 18));
|
||||
expect(plan.pieces.some((piece) => Math.abs(piece.length - 815) < 1e-6)).toBe(true);
|
||||
for (let wallIndex = 0; wallIndex < 4; wallIndex++) {
|
||||
const pieces = plan.pieces.filter((piece) => piece.wallIndex === wallIndex);
|
||||
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(3982);
|
||||
pieces.slice(1).forEach((piece, index) => {
|
||||
expect(piece.start.x).toBeCloseTo(pieces[index].end.x);
|
||||
expect(piece.start.y).toBeCloseTo(pieces[index].end.y);
|
||||
});
|
||||
}
|
||||
for (const stock of plan.stocks) {
|
||||
let available = stock.length;
|
||||
for (const id of stock.pieceIds) {
|
||||
const piece = plan.pieces.find((piece) => piece.id === id)!;
|
||||
expect(piece.length).toBeLessThanOrEqual(available + 1e-6);
|
||||
expect(piece.backLength).toBeGreaterThanOrEqual(settings.minLength - 1e-6);
|
||||
available = Math.max(0, available - piece.length - settings.kerf);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reserves the shorter front edge when splitting a run at angled internal corners', () => {
|
||||
const room: Room = {
|
||||
id: 'triangle',
|
||||
name: 'Triangle',
|
||||
direction: 0,
|
||||
walls: [0, 1, 2].map((index) => ({
|
||||
id: `w${index}`,
|
||||
length: 3100,
|
||||
turn: 120,
|
||||
doors: [],
|
||||
})),
|
||||
};
|
||||
const plan = planSkirting([room], { ...settings, stockLengths: [311] }, 5);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.pieces).toHaveLength(30);
|
||||
for (const piece of plan.pieces) {
|
||||
expect(piece.length).toBeLessThanOrEqual(311 + 1e-6);
|
||||
expect(piece.backLength).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||
expect(piece.template![2].x - piece.template![3].x).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||
}
|
||||
for (let wallIndex = 0; wallIndex < 3; wallIndex++) {
|
||||
expect(
|
||||
plan.pieces
|
||||
.filter((piece) => piece.wallIndex === wallIndex)
|
||||
.reduce((sum, piece) => sum + piece.backLength, 0),
|
||||
).toBeCloseTo(3100 - 18 / Math.sin((120 * Math.PI) / 180));
|
||||
}
|
||||
});
|
||||
|
||||
it('calculates angled butt cuts and back setbacks for non-square internal corners', () => {
|
||||
const room: Room = {
|
||||
id: 'triangle',
|
||||
name: 'Triangle',
|
||||
direction: 0,
|
||||
walls: [0, 1, 2].map((index) => ({
|
||||
id: `w${index}`,
|
||||
length: 3000,
|
||||
turn: 120,
|
||||
doors: [],
|
||||
})),
|
||||
};
|
||||
const plan = planSkirting([room], { ...settings, stockLengths: [4000] }, 5);
|
||||
expect(plan.pieces).toHaveLength(3);
|
||||
expect(plan.pieces[0].backLength).toBeCloseTo(3000 - 18 / Math.sin((120 * Math.PI) / 180));
|
||||
for (const piece of plan.pieces) {
|
||||
expect(piece.leftMitre).toBeCloseTo(30);
|
||||
expect(piece.rightMitre).toBeCloseTo(30);
|
||||
expect(piece.leftType).toBe('internal');
|
||||
expect(piece.leftBevel).toBeCloseTo(30);
|
||||
expect(piece.rightBevel).toBeCloseTo(-30);
|
||||
expect(piece.template![2].x - piece.template![3].x).toBeGreaterThan(settings.minLength);
|
||||
}
|
||||
expect(plan.cuts).toBe(9); // Three blank crosscuts and six end bevels.
|
||||
});
|
||||
|
||||
it('can leave a minimum-sized final piece without making a tiny join', () => {
|
||||
const plan = planSkirting([rectangle(2500, 2000)], { ...settings, thickness: 0 }, 5);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.pieces.every((piece) => piece.backLength >= 300 - 1e-6)).toBe(true);
|
||||
for (const wall of [0, 2])
|
||||
expect(
|
||||
plan.pieces
|
||||
.filter((piece) => piece.wallIndex === wall)
|
||||
.reduce((sum, piece) => sum + piece.backLength, 0),
|
||||
).toBeCloseTo(2500);
|
||||
});
|
||||
|
||||
it('refuses invalid outlines and stock settings', () => {
|
||||
const room = rectangle();
|
||||
room.walls[3].length += 100;
|
||||
expect(planSkirting([room], settings, 5).pieces).toEqual([]);
|
||||
expect(
|
||||
planSkirting([rectangle()], { ...settings, stockLengths: [] }, 5).issues[0].severity,
|
||||
).toBe('error');
|
||||
expect(
|
||||
planSkirting([rectangle()], { ...settings, minLength: 1e-10 }, 5).issues[0].severity,
|
||||
).toBe('error');
|
||||
});
|
||||
|
||||
it('numbers visible cuts continuously per room and remaps stock references', () => {
|
||||
const rooms = [rectangle(4000, 3000, 'one'), rectangle(4000, 3000, 'two')];
|
||||
const plan = planSkirting(rooms, settings, 5);
|
||||
const cuts = skirtingCuts(plan);
|
||||
expect(plan.pieces.length).toBeGreaterThan(cuts.length);
|
||||
for (const [roomIndex, room] of rooms.entries()) {
|
||||
const ids = cuts.filter((piece) => piece.roomId === room.id).map((piece) => piece.id);
|
||||
expect(ids.length).toBeGreaterThan(0);
|
||||
expect(ids).toEqual(ids.map((_, index) => `${roomIndex === 0 ? 'A' : 'B'}${index + 1}`));
|
||||
}
|
||||
const ids = plan.pieces.map((piece) => piece.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(plan.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual([...ids].sort());
|
||||
expect(plan).toEqual(planSkirting(rooms, settings, 5));
|
||||
});
|
||||
|
||||
it('keeps valid rooms when another room has an undersized run or unclosed outline', () => {
|
||||
const valid = rectangle(4000, 3000, 'valid');
|
||||
const small = rectangle(4000, 3000, 'small');
|
||||
small.walls[0].doors = [{ id: 'door', offset: 200, width: 900 }];
|
||||
const open = rectangle(4000, 3000, 'open');
|
||||
open.walls[0].length += 100;
|
||||
const plan = planSkirting([valid, small, open], settings, 5);
|
||||
expect(plan.pieces.length).toBeGreaterThan(0);
|
||||
expect(plan.pieces.every((piece) => piece.roomId === valid.id)).toBe(true);
|
||||
expect(
|
||||
plan.issues.filter((issue) => issue.severity === 'error').map((issue) => issue.roomId),
|
||||
).toEqual(['small', 'open']);
|
||||
expect(plan.stockCount).toBe(planSkirting([valid], settings, 5).stockCount);
|
||||
expect(
|
||||
planSkirting([valid], { ...settings, stockLengths: [] }, 5).issues[0].roomId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('attributes corner stock-fit failures to the affected room', () => {
|
||||
const valid = rectangle(4000, 3000, 'valid');
|
||||
const triangle: Room = {
|
||||
id: 'triangle',
|
||||
name: 'Triangle',
|
||||
direction: 0,
|
||||
walls: [0, 1, 2].map((index) => ({ id: `w${index}`, length: 3000, turn: 120, doors: [] })),
|
||||
};
|
||||
const plan = planSkirting(
|
||||
[valid, triangle],
|
||||
{ ...settings, stockLengths: [500], minLength: 490 },
|
||||
5,
|
||||
);
|
||||
expect(plan.pieces.length).toBeGreaterThan(0);
|
||||
expect(plan.pieces.every((piece) => piece.roomId === valid.id)).toBe(true);
|
||||
const failures = plan.issues.filter((issue) => issue.severity === 'error');
|
||||
expect(failures.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
failures.every(
|
||||
(issue) => issue.roomId === triangle.id && issue.message.includes('stock lengths'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
236
tests/url-state.test.ts
Normal file
236
tests/url-state.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
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(/^#plan=[A-Za-z0-9+$-]+$/);
|
||||
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.stockLengths = [2400, 3600, 4800];
|
||||
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({ roomId: state.view.roomId, selectedPiece: 'A4' });
|
||||
expect(Object.keys(decoded.state.view).sort()).toEqual(['roomId', '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 an empty stock list without inventing inputs', () => {
|
||||
const state = defaultState();
|
||||
state.project.rooms[0].walls = [];
|
||||
state.project.skirting.stockLengths = [];
|
||||
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 compression characters', () => {
|
||||
const hash = encodeState(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 payloads even when the decoder ignores trailing data', () => {
|
||||
const hash = encodeState(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 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();
|
||||
stock.project.skirting.stockLengths = Array(URL_LIMITS.stockLengths + 1).fill(2400);
|
||||
expectInvalid(rawLink(stock), /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('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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue