reviewed
This commit is contained in:
parent
62a385e4c9
commit
7959fe85cc
29 changed files with 1679 additions and 498 deletions
|
|
@ -3,6 +3,8 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { FlooringPreview, SkirtingPreview } from '../src/components/CutPreview';
|
||||
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 { cutMm, degree } from '../src/lib/format';
|
||||
|
|
@ -64,6 +66,23 @@ describe('cut shape previews', () => {
|
|||
expect(cutMm(600.001)).toBe('600.5');
|
||||
});
|
||||
|
||||
it('preserves exact preparation dimensions in previews of fractional stock and remnants', () => {
|
||||
const piece = {
|
||||
...floor,
|
||||
length: 597.2,
|
||||
width: 190.1,
|
||||
blankLength: 597.2,
|
||||
blankWidth: 190.2,
|
||||
reused: true,
|
||||
};
|
||||
const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece }));
|
||||
expect(markup).toContain('597.2 × 190.2 mm');
|
||||
expect(markup).toContain('>597.2</text>');
|
||||
expect(markup).toContain('>190.2</text>');
|
||||
expect(markup).not.toContain('597.5');
|
||||
expect(markup).not.toContain('190.5');
|
||||
});
|
||||
|
||||
it('keeps skirting endpoints in original coordinates and gives both ends equal square windows', () => {
|
||||
const original = structuredClone(template);
|
||||
const boxes = skirtingEndBoxes(template, 18);
|
||||
|
|
@ -208,7 +227,7 @@ describe('cut shape previews', () => {
|
|||
expect(markup).toContain('Living room · Wall 1');
|
||||
expect(markup).toContain('>1,164</text>');
|
||||
expect(markup).toContain('>1,200</text>');
|
||||
expect(markup).toContain('45° external');
|
||||
expect(markup).toContain('Mitre 45°');
|
||||
expect(markup).not.toContain('Source');
|
||||
expect(markup).not.toContain('coordinates');
|
||||
expect(markup).not.toContain('<dl');
|
||||
|
|
@ -250,4 +269,60 @@ describe('cut shape previews', () => {
|
|||
expect(floorMarkup).not.toContain('<figure');
|
||||
expect(skirtMarkup).not.toContain('<figure');
|
||||
});
|
||||
it('shows cope preparation and its full-thickness body without inventing a decorative profile', () => {
|
||||
const project = defaultProject();
|
||||
const plan = planSkirting(project.rooms, project.skirting, project.tolerance);
|
||||
const piece = plan.pieces.find((piece) => piece.leftType === 'cope')!;
|
||||
expect(piece).toBeDefined();
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(CutDialog, { skirt: piece, project, close: () => {} }),
|
||||
);
|
||||
expect(markup).toContain(`Left cope preparation close-up ${piece.id}`);
|
||||
expect(markup).toContain('Cope · prepare 45°');
|
||||
expect(markup).toContain('preparation before coping');
|
||||
expect(markup).toContain('class="skirting-cope-body"');
|
||||
expect(markup).toContain('mark your moulding’s exposed profile');
|
||||
expect(markup).toContain('Exposed front length:');
|
||||
expect(skirtingEndLabel({ ...piece, rightType: 'internal', rightMitre: 0 }, 'right')).toBe(
|
||||
'Receive cope · square',
|
||||
);
|
||||
expect(skirtingEndLabel({ ...piece, rightType: 'internal', rightMitre: 30 }, 'right')).toBe(
|
||||
'Receive cope · wall-fit 30°',
|
||||
);
|
||||
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(
|
||||
createElement(SkirtingPreview, {
|
||||
piece,
|
||||
thickness: project.skirting.thickness,
|
||||
compact: true,
|
||||
}),
|
||||
);
|
||||
expect(compact).toContain('class="skirting-cope-body"');
|
||||
});
|
||||
|
||||
it('keeps an oblique coped body inside the uniformly scaled end close-up', () => {
|
||||
const prepared = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1000, y: 0 },
|
||||
{ x: 1000, y: 18 },
|
||||
{ x: 2, y: 18 },
|
||||
];
|
||||
const installed = [
|
||||
{ x: 100, y: 0 },
|
||||
{ x: 1000, y: 0 },
|
||||
{ x: 1000, y: 18 },
|
||||
{ x: 2, y: 18 },
|
||||
];
|
||||
const boxes = skirtingEndBoxes(prepared, 18, installed);
|
||||
expect(boxes.left.width).toBe(boxes.left.height);
|
||||
for (const point of [prepared[0], prepared[3], installed[0], installed[3]]) {
|
||||
expect(point.x).toBeGreaterThan(boxes.left.x);
|
||||
expect(point.x).toBeLessThan(boxes.left.x + boxes.left.width);
|
||||
expect(point.y).toBeGreaterThan(boxes.left.y);
|
||||
expect(point.y).toBeLessThan(boxes.left.y + boxes.left.height);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
6
tests/fixtures/angled-room-f8.json
vendored
6
tests/fixtures/angled-room-f8.json
vendored
|
|
@ -113,9 +113,7 @@
|
|||
"kerf": 3
|
||||
},
|
||||
"skirting": {
|
||||
"stockLengths": [
|
||||
2400
|
||||
],
|
||||
"stockLength": 2400,
|
||||
"thickness": 27,
|
||||
"minLength": 200,
|
||||
"kerf": 3
|
||||
|
|
@ -129,4 +127,4 @@
|
|||
"search": "F8",
|
||||
"collapsedSections": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
tests/fixtures/angled-room.json
vendored
2
tests/fixtures/angled-room.json
vendored
|
|
@ -101,7 +101,7 @@
|
|||
"kerf": 3
|
||||
},
|
||||
"skirting": {
|
||||
"stockLengths": [2400],
|
||||
"stockLength": 2400,
|
||||
"thickness": 27,
|
||||
"minLength": 200,
|
||||
"kerf": 3
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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 { cutLength } from '../src/lib/format';
|
||||
import type { FlooringPlan, FlooringSettings, Point, Project, Room } from '../src/lib/types';
|
||||
|
||||
const settings: FlooringSettings = {
|
||||
|
|
@ -369,4 +370,106 @@ describe('laminate planning', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('temporary F8 audit',()=>{const project=f8Fixture.project as Project;const plan=planFlooring(project.rooms,project.flooring,project.tolerance);const atCorner=plan.pieces.filter(p=>p.polygon.some(v=>Math.hypot(v.x-7607.513,v.y-2160.340)<0.01));throw new Error(JSON.stringify({atCorner,first:plan.rows[0],last:plan.rows.at(-1),boards:plan.boardCount,cuts:plan.cuts,area:plan.coveredArea,insetArea:insetPolygon(buildGeometry(project.rooms[0],project.tolerance).vertices,10).reduce((a,p)=>a+Math.abs(signedArea(p)),0)}));});
|
||||
describe('angled room cut allocation', () => {
|
||||
const project = f8Fixture.project as Project;
|
||||
const plan = planFlooring(project.rooms, project.flooring, project.tolerance);
|
||||
|
||||
it('aligns a row seam with the inward corner that previously left F8 a thin toe', () => {
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(project.rooms[0].walls).toHaveLength(13);
|
||||
expect(project.rooms[0].walls.flatMap((wall) => wall.doors)).toHaveLength(2);
|
||||
const corner = { x: 7607.513, y: 2160.34 };
|
||||
const adjoining = plan.pieces.filter((piece) =>
|
||||
piece.polygon.some((point) => Math.hypot(point.x - corner.x, point.y - corner.y) < 0.01),
|
||||
);
|
||||
expect(adjoining.length).toBeGreaterThanOrEqual(2);
|
||||
expect(new Set(adjoining.map((piece) => piece.row)).size).toBe(2);
|
||||
for (const piece of adjoining) {
|
||||
// Boards run north/south: an x-boundary is the uncut factory long-edge seam.
|
||||
const xs = piece.polygon.map((point) => point.x);
|
||||
expect(
|
||||
Math.min(Math.abs(Math.min(...xs) - corner.x), Math.abs(Math.max(...xs) - corner.x)),
|
||||
).toBeLessThan(0.01);
|
||||
const turns = piece.polygon.map((point, index, polygon) => {
|
||||
const previous = polygon[(index + polygon.length - 1) % polygon.length];
|
||||
const next = polygon[(index + 1) % polygon.length];
|
||||
return (
|
||||
(point.x - previous.x) * (next.y - point.y) - (point.y - previous.y) * (next.x - point.x)
|
||||
);
|
||||
});
|
||||
expect(turns.every((turn) => turn >= -0.01) || turns.every((turn) => turn <= 0.01)).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
expectCoverage(plan, project.rooms[0], project.flooring.gap);
|
||||
expectOffsets(plan, project.flooring);
|
||||
expectStockFits(plan, project.flooring);
|
||||
expect(plan).toEqual(planFlooring(project.rooms, project.flooring, project.tolerance));
|
||||
});
|
||||
|
||||
it('fits all displayed half-millimetre blanks and their kerfs on each purchased board', () => {
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(plan.pieces.some((piece) => cutLength(piece.length) > piece.length + 0.01)).toBe(true);
|
||||
for (const stock of plan.stocks) {
|
||||
const pieces = stock.pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
|
||||
const rounded = pieces.reduce((sum, piece) => sum + cutLength(piece.length), 0);
|
||||
expect(
|
||||
rounded + (pieces.length - 1) * project.flooring.kerf,
|
||||
`${stock.id}: ${pieces.map((piece) => `${piece.id}=${cutLength(piece.length)}`).join(', ')}`,
|
||||
).toBeLessThanOrEqual(project.flooring.length + 0.001);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves whole factory boards whose purchased length is not a half millimetre', () => {
|
||||
const s = {
|
||||
...settings,
|
||||
length: 1200.2,
|
||||
minOffset: 0,
|
||||
maxOffset: 1200.2,
|
||||
pattern: 'random' as const,
|
||||
};
|
||||
const r = room(1220.2, 220);
|
||||
const fitted = planFlooring([r], s, 1);
|
||||
expect(errors(fitted)).toEqual([]);
|
||||
expect(fitted.boardCount).toBe(1);
|
||||
expect(fitted.cuts).toBe(0);
|
||||
expect(fitted.pieces[0].length).toBe(1200.2);
|
||||
expect(fitted.pieces[0].blankLength).toBe(1200.2);
|
||||
expect(fitted.stocks[0].remaining).toBe(0);
|
||||
expectCoverage(fitted, r, s.gap);
|
||||
});
|
||||
it('uses an exact-fit fractional offcut without pretending it needs a new cut', () => {
|
||||
const s = { ...settings, length: 1200.2, pattern: 'random' as const };
|
||||
const fitted = planFlooring([room(620, 220, 'first'), room(617.2, 220, 'second')], s, 1);
|
||||
expect(errors(fitted)).toEqual([]);
|
||||
expect(fitted.boardCount).toBe(1);
|
||||
expect(fitted.cuts).toBe(1);
|
||||
expect(fitted.pieces.map((piece) => piece.length)).toEqual([600, 597.2]);
|
||||
expect(fitted.pieces.map((piece) => piece.blankLength)).toEqual([600, 597.2]);
|
||||
expect(fitted.pieces[1].reused).toBe(true);
|
||||
expect(fitted.pieces[1].cut).toBe(false);
|
||||
expect(fitted.stocks[0].remaining).toBe(0);
|
||||
expectStockFits(fitted, s);
|
||||
});
|
||||
|
||||
it('keeps a finishing cut visible when its rounded blank consumes a complete board', () => {
|
||||
const s = { ...settings, pattern: 'random' as const };
|
||||
const fitted = planFlooring([room(1219.6, 220)], s, 1);
|
||||
expect(errors(fitted)).toEqual([]);
|
||||
expect(fitted.boardCount).toBe(1);
|
||||
expect(fitted.pieces[0].length).toBe(1199.6);
|
||||
expect(fitted.pieces[0].cut).toBe(true);
|
||||
expect(fitted.cuts).toBe(1);
|
||||
expect(fitted.stocks[0].remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('caps a rounded rip blank at the available fractional factory width', () => {
|
||||
const s = { ...settings, width: 200.2, pattern: 'random' as const };
|
||||
const fitted = planFlooring([room(1220, 220.1)], s, 1);
|
||||
expect(errors(fitted)).toEqual([]);
|
||||
expect(fitted.boardCount).toBe(1);
|
||||
expect(fitted.pieces[0].width).toBe(200.1);
|
||||
expect(fitted.pieces[0].blankWidth).toBe(200.2);
|
||||
expect(fitted.pieces[0].cut).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
67
tests/piece-labels.test.ts
Normal file
67
tests/piece-labels.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultProject, fireplace } from '../src/lib/defaults';
|
||||
import { buildGeometry } from '../src/lib/geometry';
|
||||
import { planFlooring } from '../src/lib/flooring';
|
||||
import { floorPieceLabels } from '../src/lib/piece-labels';
|
||||
|
||||
describe('floorplan piece labels', () => {
|
||||
const room = fireplace('room-2', 'Room 2');
|
||||
const settings = {
|
||||
...defaultProject().flooring,
|
||||
pattern: 'random' as const,
|
||||
minOffset: 100,
|
||||
maxOffset: 1000,
|
||||
minLength: 10,
|
||||
minWidth: 0.1,
|
||||
};
|
||||
const plan = planFlooring([room], settings, 10);
|
||||
const geometry = buildGeometry(room, 10);
|
||||
const cuts = plan.pieces.filter((piece) => piece.cut || piece.reused);
|
||||
|
||||
it('labels short positive cuts without covering neighboring labels or changing their geometry', () => {
|
||||
const before = structuredClone(plan.pieces);
|
||||
const labels = floorPieceLabels(plan.pieces, geometry.vertices, 0, 4800 * 0.024);
|
||||
expect(labels.map((label) => label.id)).toEqual(cuts.map((piece) => piece.id));
|
||||
const small = cuts.filter((piece) => piece.length < 180);
|
||||
expect(small.length).toBeGreaterThan(0);
|
||||
for (const piece of small) {
|
||||
expect(piece.length).toBeGreaterThan(0);
|
||||
expect(piece.width).toBeGreaterThan(0);
|
||||
expect(labels.find((label) => label.id === piece.id)!.leader).toBe(true);
|
||||
}
|
||||
for (const [index, label] of labels.entries()) {
|
||||
expect(label.box.minX).toBeGreaterThanOrEqual(0);
|
||||
expect(label.box.maxX).toBeLessThanOrEqual(4800);
|
||||
for (const other of labels.slice(index + 1)) {
|
||||
expect(
|
||||
label.box.minX >= other.box.maxX ||
|
||||
label.box.maxX <= other.box.minX ||
|
||||
label.box.minY >= other.box.maxY ||
|
||||
label.box.maxY <= other.box.minY,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(plan.pieces).toEqual(before);
|
||||
expect(floorPieceLabels(plan.pieces, geometry.vertices, 0, 4800 * 0.024)).toEqual(labels);
|
||||
});
|
||||
|
||||
it('allows space for complete three-digit codes and completion marks in either direction', () => {
|
||||
const piece = { ...cuts[0], id: 'F999' };
|
||||
for (const direction of [0, 90] as const) {
|
||||
const plain = floorPieceLabels([piece], geometry.vertices, direction, 100)[0];
|
||||
const done = floorPieceLabels(
|
||||
[piece],
|
||||
geometry.vertices,
|
||||
direction,
|
||||
100,
|
||||
[],
|
||||
[`floor:${piece.stockId}`],
|
||||
)[0];
|
||||
expect(done.text).toBe('F999 ✓');
|
||||
expect(done.width).toBeGreaterThan(plain.width);
|
||||
expect(done.angle).toBe(direction);
|
||||
expect(done.box.maxX - done.box.minX).toBeCloseTo(direction === 0 ? done.width : done.height);
|
||||
expect(done.box.maxY - done.box.minY).toBeCloseTo(direction === 0 ? done.height : done.width);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,7 @@ 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 };
|
||||
const settings = { stockLength: 2400, thickness: 18, minLength: 200, kerf: 3 };
|
||||
|
||||
function overlap(a: Point[], b: Point[]): number {
|
||||
const clipper = new ClipperLib.Clipper();
|
||||
|
|
@ -56,10 +56,14 @@ function expectCompleteSkirting(room: Room) {
|
|||
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);
|
||||
const piece = plan.pieces[i];
|
||||
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),
|
||||
);
|
||||
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),
|
||||
);
|
||||
expect(
|
||||
Math.abs(overlap(polygon, geometry.vertices) - Math.abs(signedArea(polygon))),
|
||||
).toBeLessThan(clippingTolerance);
|
||||
|
|
@ -73,7 +77,7 @@ function expectCompleteSkirting(room: Room) {
|
|||
|
||||
describe('installed skirting polygons', () => {
|
||||
it.each([1, -1])(
|
||||
'covers a rectangle including uncut stock without overlapping butt joints (orientation %i)',
|
||||
'covers a rectangle including uncut stock without overlapping coped bodies (orientation %i)',
|
||||
(orientation) => {
|
||||
const room = rectangle();
|
||||
room.walls.forEach((wall) => {
|
||||
|
|
@ -93,19 +97,29 @@ describe('installed skirting polygons', () => {
|
|||
});
|
||||
const plan = expectCompleteSkirting(room);
|
||||
expect(
|
||||
plan.pieces.some(
|
||||
(piece) => piece.leftType === 'external' && piece.rightType === 'external',
|
||||
plan.pieces.reduce(
|
||||
(sum, piece) =>
|
||||
sum + Number(piece.leftType === 'external') + Number(piece.rightType === 'external'),
|
||||
0,
|
||||
),
|
||||
).toBe(true);
|
||||
).toBe(4);
|
||||
},
|
||||
);
|
||||
|
||||
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: [] })),
|
||||
});
|
||||
});
|
||||
it.each([60, -60, 120, -120])(
|
||||
'matches the room boundary at angled coped internal joints (%i degrees)',
|
||||
(turn) => {
|
||||
expectCompleteSkirting({
|
||||
id: 'triangle',
|
||||
name: 'Triangle',
|
||||
direction: 0,
|
||||
walls: Array.from({ length: 360 / Math.abs(turn) }, (_, index) => ({
|
||||
id: `w${index}`,
|
||||
length: 3000,
|
||||
turn,
|
||||
doors: [],
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { skirtingCuts } from '../src/lib/cut-list';
|
|||
import { cutLength } from '../src/lib/format';
|
||||
import type { Room, SkirtingSettings } from '../src/lib/types';
|
||||
|
||||
const settings: SkirtingSettings = { stockLengths: [2400], thickness: 18, minLength: 300, kerf: 3 };
|
||||
const settings: SkirtingSettings = { stockLength: 2400, thickness: 18, minLength: 300, kerf: 3 };
|
||||
const rectangle = (width = 4000, height = 3000, id = 'r1'): Room => ({
|
||||
id,
|
||||
name: id,
|
||||
|
|
@ -18,23 +18,20 @@ const rectangle = (width = 4000, height = 3000, id = 'r1'): Room => ({
|
|||
});
|
||||
|
||||
describe('skirting cutting plan', () => {
|
||||
it('subtracts internal butt thickness and preserves every installed wall run', () => {
|
||||
it('retains coping overlap in the wall-length blanks and preserves every visible 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.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(14_000);
|
||||
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);
|
||||
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(
|
||||
[4000, 3000, 4000, 3000][wall] - 36,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -43,7 +40,7 @@ describe('skirting cutting plan', () => {
|
|||
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.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(4000 - 900);
|
||||
expect(
|
||||
pieces.some((piece) => Math.abs(piece.end.x - 1000) < 1e-6 && piece.rightType === 'square'),
|
||||
).toBe(true);
|
||||
|
|
@ -96,7 +93,7 @@ describe('skirting cutting plan', () => {
|
|||
doors: [],
|
||||
})),
|
||||
};
|
||||
const plan = planSkirting([room], { ...settings, stockLengths: [6000] }, 5);
|
||||
const plan = planSkirting([room], { ...settings, stockLength: 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);
|
||||
|
|
@ -108,7 +105,7 @@ describe('skirting cutting plan', () => {
|
|||
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 reversePlan = planSkirting([reversed], { ...settings, stockLength: 6000 }, 5);
|
||||
const reverseFront = reversePlan.pieces.find((piece) => piece.wallIndex === 4)!;
|
||||
expect(reverseFront.length).toBeCloseTo(1036);
|
||||
expect(reverseFront.leftBevel).toBeCloseTo(-45);
|
||||
|
|
@ -117,7 +114,7 @@ describe('skirting cutting plan', () => {
|
|||
|
||||
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 options = { ...settings, stockLength: 1800, 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);
|
||||
|
|
@ -127,7 +124,7 @@ describe('skirting cutting plan', () => {
|
|||
expect(plan.waste).toBeCloseTo(0);
|
||||
const trimmed = planSkirting(
|
||||
[rectangle(1199, 1199)],
|
||||
{ ...settings, stockLengths: [2400], thickness: 0, minLength: 1199 },
|
||||
{ ...settings, stockLength: 2400, thickness: 0, minLength: 1199 },
|
||||
5,
|
||||
);
|
||||
expect(trimmed.stockCount).toBe(4); // 1199 + 3 + 1199 cannot fit into 2400.
|
||||
|
|
@ -138,7 +135,7 @@ describe('skirting cutting plan', () => {
|
|||
it('allows the final saw pass to extend beyond a short stock tail', () => {
|
||||
const plan = planSkirting(
|
||||
[rectangle(1199, 1199)],
|
||||
{ ...settings, stockLengths: [1200], thickness: 0 },
|
||||
{ ...settings, stockLength: 1200, thickness: 0 },
|
||||
5,
|
||||
);
|
||||
expect(plan.pieces).toHaveLength(4);
|
||||
|
|
@ -163,10 +160,10 @@ describe('skirting cutting plan', () => {
|
|||
expect(plan.pieces.reduce((sum, piece) => sum + piece.length, 0)).toBeCloseTo(4794);
|
||||
});
|
||||
|
||||
it('looks ahead when choosing from different stock lengths', () => {
|
||||
it('packs multiple cuts into the single purchased stock length', () => {
|
||||
const plan = planSkirting(
|
||||
[rectangle(1200, 1200)],
|
||||
{ ...settings, stockLengths: [1800, 2400], thickness: 0, kerf: 0 },
|
||||
{ ...settings, stockLength: 2400, thickness: 0, kerf: 0 },
|
||||
5,
|
||||
);
|
||||
expect(plan.stockCount).toBe(2);
|
||||
|
|
@ -174,17 +171,66 @@ describe('skirting cutting plan', () => {
|
|||
expect(plan.waste).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('lets reused tails set wall joints to save a stock length without extra saw cuts', () => {
|
||||
it('discards rounded splits that exceed fractional stock while retaining a fitting candidate', () => {
|
||||
const plan = planSkirting([rectangle(2400.1, 2400.1)], { ...settings, stockLength: 1200.2 }, 5);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.pieces).toHaveLength(8);
|
||||
for (let wall = 0; wall < 4; wall++) {
|
||||
expect(
|
||||
plan.pieces
|
||||
.filter((piece) => piece.wallIndex === wall)
|
||||
.reduce((sum, piece) => sum + piece.backLength, 0),
|
||||
).toBeCloseTo(2400.1);
|
||||
}
|
||||
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)!;
|
||||
const blank =
|
||||
Math.abs(piece.length - available) < 1e-6 ? available : cutLength(piece.length);
|
||||
expect(blank).toBeLessThanOrEqual(available + 1e-6);
|
||||
expect(piece.blankLength).toBeCloseTo(blank);
|
||||
expect(piece.visibleLength).toBeGreaterThanOrEqual(settings.minLength - 1e-6);
|
||||
available = Math.max(0, available - blank - settings.kerf);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('adds a joint when fractional stock cannot supply the rounded two-piece split', () => {
|
||||
const plan = planSkirting([rectangle(2400.3, 2400.3)], { ...settings, stockLength: 1200.2 }, 5);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.pieces.length).toBeGreaterThan(8);
|
||||
for (let wall = 0; wall < 4; wall++) {
|
||||
expect(
|
||||
plan.pieces
|
||||
.filter((piece) => piece.wallIndex === wall)
|
||||
.reduce((sum, piece) => sum + piece.backLength, 0),
|
||||
).toBeCloseTo(2400.3);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps another room when rounding makes a minimum-sized split impossible', () => {
|
||||
const valid = rectangle(2400.4, 2400.4, 'valid');
|
||||
const invalid = rectangle(2400.1, 2400.1, 'invalid');
|
||||
const options = { ...settings, stockLength: 1200.2, thickness: 0, minLength: 1200.05 };
|
||||
const plan = planSkirting([valid, invalid], options, 5);
|
||||
expect(plan.pieces).toEqual(planSkirting([valid], options, 5).pieces);
|
||||
expect(plan.pieces).toHaveLength(8);
|
||||
const failures = plan.issues.filter((issue) => issue.severity === 'error');
|
||||
expect(failures.length).toBeGreaterThan(0);
|
||||
expect(failures.every((issue) => issue.roomId === invalid.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('lets reused tails set wall joints while counting preparation and coping operations', () => {
|
||||
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);
|
||||
expect(plan.cuts).toBe(13);
|
||||
expect(plan.waste).toBeCloseTo(7 * 2400 - 16000);
|
||||
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);
|
||||
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(4000);
|
||||
pieces.slice(1).forEach((piece, index) => {
|
||||
expect(piece.start.x).toBeCloseTo(pieces[index].end.x);
|
||||
expect(piece.start.y).toBeCloseTo(pieces[index].end.y);
|
||||
|
|
@ -213,24 +259,25 @@ describe('skirting cutting plan', () => {
|
|||
doors: [],
|
||||
})),
|
||||
};
|
||||
const plan = planSkirting([room], { ...settings, stockLengths: [311] }, 5);
|
||||
const plan = planSkirting([room], { ...settings, stockLength: 340 }, 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.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.visibleLength).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));
|
||||
).toBeCloseTo(3100);
|
||||
}
|
||||
});
|
||||
|
||||
it('calculates angled butt cuts and back setbacks for non-square internal corners', () => {
|
||||
it('prepares oblique copes at half-turn angles and wall-fits their receiving ends', () => {
|
||||
const room: Room = {
|
||||
id: 'triangle',
|
||||
name: 'Triangle',
|
||||
|
|
@ -242,18 +289,19 @@ describe('skirting cutting plan', () => {
|
|||
doors: [],
|
||||
})),
|
||||
};
|
||||
const plan = planSkirting([room], { ...settings, stockLengths: [4000] }, 5);
|
||||
const plan = planSkirting([room], { ...settings, stockLength: 4000 }, 5);
|
||||
expect(plan.pieces).toHaveLength(3);
|
||||
expect(plan.pieces[0].backLength).toBeCloseTo(3000 - 18 / Math.sin((120 * Math.PI) / 180));
|
||||
expect(plan.pieces.every((piece) => Math.abs(piece.backLength - 3000) < 1e-6)).toBe(true);
|
||||
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(piece.leftMitre).toBeCloseTo(piece.wallIndex === 0 ? 30 : 60);
|
||||
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(
|
||||
settings.minLength,
|
||||
);
|
||||
}
|
||||
expect(plan.cuts).toBe(9); // Three blank crosscuts and six end bevels.
|
||||
expect(plan.cuts).toBe(12); // Three blank crosscuts, six preparation bevels, three copes.
|
||||
});
|
||||
|
||||
it('can leave a minimum-sized final piece without making a tiny join', () => {
|
||||
|
|
@ -272,9 +320,9 @@ describe('skirting cutting plan', () => {
|
|||
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, stockLength: 0 }, 5).issues[0].severity).toBe(
|
||||
'error',
|
||||
);
|
||||
expect(
|
||||
planSkirting([rectangle()], { ...settings, minLength: 1e-10 }, 5).issues[0].severity,
|
||||
).toBe('error');
|
||||
|
|
@ -310,12 +358,15 @@ describe('skirting cutting plan', () => {
|
|||
).toEqual(['small', 'open']);
|
||||
expect(plan.stockCount).toBe(planSkirting([valid], settings, 5).stockCount);
|
||||
expect(
|
||||
planSkirting([valid], { ...settings, stockLengths: [] }, 5).issues[0].roomId,
|
||||
planSkirting([valid], { ...settings, stockLength: 0 }, 5).issues[0].roomId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('attributes corner stock-fit failures to the affected room', () => {
|
||||
const valid = rectangle(4000, 3000, 'valid');
|
||||
const valid = rectangle(4100, 4100, 'valid');
|
||||
valid.walls.forEach((wall) => {
|
||||
wall.doors = [{ id: `${wall.id}-door`, offset: 0, width: 100 }];
|
||||
});
|
||||
const triangle: Room = {
|
||||
id: 'triangle',
|
||||
name: 'Triangle',
|
||||
|
|
@ -324,7 +375,7 @@ describe('skirting cutting plan', () => {
|
|||
};
|
||||
const plan = planSkirting(
|
||||
[valid, triangle],
|
||||
{ ...settings, stockLengths: [500], minLength: 490 },
|
||||
{ ...settings, stockLength: 500, minLength: 490 },
|
||||
5,
|
||||
);
|
||||
expect(plan.pieces.length).toBeGreaterThan(0);
|
||||
|
|
@ -337,4 +388,101 @@ describe('skirting cutting plan', () => {
|
|||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it('counts full-stock copes as cuts and uses a receiving first wall to close the installation order', () => {
|
||||
const plan = planSkirting([rectangle(2400, 2400)], settings, 1);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.stockCount).toBe(4);
|
||||
expect(plan.cuts).toBe(8); // Four preparation mitres and four profile-coping operations.
|
||||
for (const piece of plan.pieces) expect(piece.length).toBeCloseTo(2400);
|
||||
expect(plan.pieces.map((piece) => [piece.leftType, piece.rightType])).toEqual([
|
||||
['internal', 'internal'],
|
||||
['cope', 'internal'],
|
||||
['cope', 'internal'],
|
||||
['cope', 'cope'],
|
||||
]);
|
||||
expect(skirtingCuts(plan).map((piece) => piece.id)).toEqual(['A1', 'A2', 'A3']);
|
||||
expect(plan.pieces[0].id).toMatch(/^SU/);
|
||||
for (const piece of plan.pieces) {
|
||||
expect(piece.visibleLength).toBeCloseTo(2364);
|
||||
expect(piece.leftOverlap).toBeCloseTo(18);
|
||||
expect(piece.rightOverlap).toBeCloseTo(18);
|
||||
}
|
||||
const two = planSkirting(
|
||||
[rectangle(2400, 2400, 'one'), rectangle(2400, 2400, 'two')],
|
||||
settings,
|
||||
1,
|
||||
);
|
||||
const ids = two.pieces.map((piece) => piece.id);
|
||||
expect(new Set(ids).size).toBe(8);
|
||||
expect(two.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual([...ids].sort());
|
||||
});
|
||||
|
||||
it('does not cope across a door opening that removes the receiving corner', () => {
|
||||
const r = rectangle(2400, 2400);
|
||||
r.walls[0].doors = [{ id: 'corner-door', offset: 0, width: 900 }];
|
||||
const plan = planSkirting([r], settings, 1);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.pieces.filter((piece) => piece.wallIndex === 0)[0].leftType).toBe('square');
|
||||
expect(plan.pieces.filter((piece) => piece.wallIndex === 3).at(-1)!.rightType).toBe('square');
|
||||
expect(
|
||||
plan.pieces.reduce(
|
||||
(sum, piece) =>
|
||||
sum + Number(piece.leftType === 'cope') + Number(piece.rightType === 'cope'),
|
||||
0,
|
||||
),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('rejects an exposed run shorter than the minimum even when the preparation blank is long enough', () => {
|
||||
const plan = planSkirting([rectangle(330, 2400)], settings, 1);
|
||||
expect(plan.pieces).toEqual([]); // 330 mm blank, but only 330 - 2*18 = 294 mm is visible.
|
||||
expect(
|
||||
plan.issues.some((issue) => issue.severity === 'error' && issue.message.includes('minimum')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([1, -1])(
|
||||
'reserves oblique overlap at obtuse internal corners (orientation %i)',
|
||||
(orientation) => {
|
||||
const r: Room = {
|
||||
id: 'hexagon',
|
||||
name: 'Hexagon',
|
||||
direction: 0,
|
||||
walls: Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `w${index}`,
|
||||
length: 1000.2,
|
||||
turn: 60 * orientation,
|
||||
doors: [],
|
||||
})),
|
||||
};
|
||||
const plan = planSkirting([r], { ...settings, stockLength: 2400 }, 1);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
const allowance = 18 / Math.tan(Math.PI / 3);
|
||||
expect(plan.pieces[0].length).toBeCloseTo(1000.2 + 2 * allowance);
|
||||
expect(plan.pieces.at(-1)!.length).toBeCloseTo(1000.2);
|
||||
expect(plan.pieces.at(-1)!.leftMitre).toBeCloseTo(30);
|
||||
expect(plan.pieces.at(-1)!.rightMitre).toBeCloseTo(30);
|
||||
for (const piece of plan.pieces)
|
||||
expect(piece.visibleLength).toBeCloseTo(1000.2 - 36 * Math.tan(Math.PI / 6));
|
||||
for (const stock of plan.stocks) {
|
||||
const pieces = stock.pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
|
||||
expect(
|
||||
pieces.reduce((sum, piece) => sum + cutLength(piece.length), 0) +
|
||||
(pieces.length - 1) * settings.kerf,
|
||||
).toBeLessThanOrEqual(stock.length);
|
||||
}
|
||||
expect(plan).toEqual(planSkirting([r], { ...settings, stockLength: 2400 }, 1));
|
||||
},
|
||||
);
|
||||
it('counts a finishing crosscut when the rounded blank uses the complete available stock', () => {
|
||||
const plan = planSkirting(
|
||||
[rectangle(1199.6, 1199.6)],
|
||||
{ ...settings, stockLength: 1200, thickness: 0 },
|
||||
1,
|
||||
);
|
||||
expect(plan.stockCount).toBe(4);
|
||||
expect(plan.cuts).toBe(4);
|
||||
expect(skirtingCuts(plan)).toHaveLength(4);
|
||||
expect(plan.pieces.every((piece) => cutLength(piece.length) === 1200)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ describe('URL state', () => {
|
|||
expect(hash).toMatch(/^\?version=1&name=/);
|
||||
const params = new URLSearchParams(hash);
|
||||
expect(params.get('flooring.length')).toBe('1200');
|
||||
expect(params.get('skirting.length')).toBe('2400');
|
||||
expect(params.has('skirting.stock')).toBe(false);
|
||||
expect(params.get('room.1.wall.1.length')).toBe('4800');
|
||||
expect(params.get('room.1.wall.4.door.1.width')).toBe('826');
|
||||
expect(hash).not.toContain('#plan=');
|
||||
|
|
@ -44,7 +46,7 @@ describe('URL state', () => {
|
|||
state.project.rooms[1].direction = 90;
|
||||
state.project.rooms[0].walls[0].doors = [{ id: 'wide-door', offset: 123.75, width: 1500 }];
|
||||
state.project.flooring.pattern = 'random';
|
||||
state.project.skirting.stockLengths = [2400, 3600, 4800];
|
||||
state.project.skirting.stockLength = 3600;
|
||||
state.view = { ...state.view, roomId: 'room-2', selectedPiece: 'A12' };
|
||||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||
});
|
||||
|
|
@ -96,10 +98,10 @@ describe('URL state', () => {
|
|||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it('allows an unfinished room and an empty stock list without inventing inputs', () => {
|
||||
it('allows an unfinished room and invalid board length without inventing inputs', () => {
|
||||
const state = defaultState();
|
||||
state.project.rooms[0].walls = [];
|
||||
state.project.skirting.stockLengths = [];
|
||||
state.project.skirting.stockLength = 0;
|
||||
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
|
|
@ -196,7 +198,7 @@ describe('URL state', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('rejects too many rooms, walls, doors or stock lengths', () => {
|
||||
it('rejects too many rooms, walls, doors or legacy stock lengths', () => {
|
||||
const rooms = defaultState();
|
||||
rooms.project.rooms = Array.from({ length: URL_LIMITS.rooms + 1 }, (_, i) =>
|
||||
rectangle(`room-${i}`),
|
||||
|
|
@ -217,8 +219,20 @@ describe('URL state', () => {
|
|||
);
|
||||
expectInvalid(rawLink(doors), /at most 30/);
|
||||
const stock = defaultState();
|
||||
stock.project.skirting.stockLengths = Array(URL_LIMITS.stockLengths + 1).fill(2400);
|
||||
expectInvalid(rawLink(stock), /at most 30/);
|
||||
expectInvalid(
|
||||
rawLink({
|
||||
...stock,
|
||||
project: {
|
||||
...stock.project,
|
||||
skirting: {
|
||||
...stock.project.skirting,
|
||||
stockLength: undefined,
|
||||
stockLengths: Array(URL_LIMITS.legacyStockLengths + 1).fill(2400),
|
||||
},
|
||||
},
|
||||
}),
|
||||
/at most 30/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects no rooms, duplicate identifiers and oversized strings', () => {
|
||||
|
|
@ -243,19 +257,16 @@ describe('URL state', () => {
|
|||
params.set('flooring.maxOffset', '700');
|
||||
params.set('room.1.wall.1.length', '5123.5');
|
||||
params.set('room.1.wall.4.door.1.width', '900');
|
||||
params.append('skirting.stock', '3600');
|
||||
params.set('skirting.length', '3600');
|
||||
params.set('selectedCut', 'F12');
|
||||
const result = decodeState(`?${params}`);
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.state.project.flooring.length).toBe(1400);
|
||||
expect(result.state.project.rooms[0].walls[0].length).toBe(5123.5);
|
||||
expect(result.state.project.rooms[0].walls[3].doors[0].width).toBe(900);
|
||||
expect(result.state.project.skirting.stockLengths).toEqual([2400, 3600]);
|
||||
expect(result.state.project.skirting.stockLength).toBe(3600);
|
||||
expect(result.state.view.selectedPiece).toBe('F12');
|
||||
const reversed = new URLSearchParams([...params].reverse());
|
||||
// Repeated stock values retain their order; every other key can move freely.
|
||||
reversed.delete('skirting.stock');
|
||||
params.getAll('skirting.stock').forEach((length) => reversed.append('skirting.stock', length));
|
||||
expect(encodeState(decodeState(`?${reversed}`).state)).toBe(encodeState(result.state));
|
||||
});
|
||||
|
||||
|
|
@ -286,12 +297,12 @@ describe('URL state', () => {
|
|||
expect(new URLSearchParams(encoded).has('room.1.wall.2.length')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves explicitly empty stocks and rooms while omitting an unselected cut', () => {
|
||||
it('preserves a zero board length and empty room while omitting an unselected cut', () => {
|
||||
const state = defaultState();
|
||||
state.project.skirting.stockLengths = [];
|
||||
state.project.skirting.stockLength = 0;
|
||||
state.project.rooms[0].walls = [];
|
||||
const query = encodeState(state);
|
||||
expect(query).toContain('skirting.stock=');
|
||||
expect(query).toContain('skirting.length=0');
|
||||
expect(query).not.toContain('selectedCut=');
|
||||
expect(decodeState(query)).toEqual({ state, error: null });
|
||||
});
|
||||
|
|
@ -313,15 +324,100 @@ describe('URL state', () => {
|
|||
'?name=%C0%AF',
|
||||
])('rejects an invalid editable query %s', (query) => expectInvalid(query));
|
||||
|
||||
it('rejects ambiguous duplicate single-value fields but permits multiple stock lengths', () => {
|
||||
it('rejects duplicate single-value fields and migrates repeated legacy stock lengths', () => {
|
||||
expectInvalid('?flooring.length=1200&flooring.length=1400', /only once/);
|
||||
expectInvalid('?selectedRoom=one&selectedRoom=two', /only once/);
|
||||
expectInvalid('?room.1.name=Office&room.1.name=Kitchen', /only once/);
|
||||
const result = decodeState('?skirting.stock=2400&skirting.stock=3600');
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.state.project.skirting.stockLengths).toEqual([2400, 3600]);
|
||||
expect(result.state.project.skirting.stockLength).toBe(2400);
|
||||
expectInvalid('?skirting.length=2400&skirting.length=3600', /only once/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ lengths: [3600, 2400], chosen: 3600, completed: [] },
|
||||
{ lengths: [2400, 3600], chosen: 2400, completed: [] },
|
||||
{ lengths: [3600], chosen: 3600, completed: [] },
|
||||
{ lengths: [3600, 3600], chosen: 3600, completed: [] },
|
||||
{ lengths: [], chosen: 0, completed: [] },
|
||||
])(
|
||||
'migrates old stock sizes $lengths consistently in query and compressed links',
|
||||
({ lengths, chosen, completed }) => {
|
||||
const state = defaultState();
|
||||
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
|
||||
const old = {
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
skirting: { ...state.project.skirting, stockLength: undefined, stockLengths: lengths },
|
||||
},
|
||||
};
|
||||
const query = new URLSearchParams();
|
||||
for (const length of lengths.length ? lengths : [''])
|
||||
query.append('skirting.stock', String(length));
|
||||
for (const board of state.project.completedBoards) query.append('done', board);
|
||||
state.project.skirting.stockLength = chosen;
|
||||
state.project.completedBoards = completed;
|
||||
for (const link of [rawLink(old), `?${query}`]) {
|
||||
const result = decodeState(link);
|
||||
expect(result).toEqual({ state, error: null });
|
||||
const canonical = encodeState(result.state);
|
||||
expect(canonical).toContain(`skirting.length=${chosen}`);
|
||||
expect(canonical).not.toContain('skirting.stock');
|
||||
expect(decodeState(canonical)).toEqual(result);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['?done=skirting:S4', '?skirting.stock=2400&done=skirting:S4'])(
|
||||
'clears completion from earlier calculations in %s and preserves new progress after migration',
|
||||
(link) => {
|
||||
const migrated = decodeState(link);
|
||||
expect(migrated.error).toBeNull();
|
||||
expect(migrated.state.project.completedBoards).toEqual([]);
|
||||
migrated.state.project.completedBoards = ['skirting:S4'];
|
||||
expect(decodeState(encodeState(migrated.state))).toEqual(migrated);
|
||||
},
|
||||
);
|
||||
|
||||
it('prefers the current length field over legacy stock lists without clearing progress', () => {
|
||||
const state = defaultState();
|
||||
state.project.skirting.stockLength = 4800;
|
||||
state.project.completedBoards = ['skirting:S6'];
|
||||
const legacy = {
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
skirting: { ...state.project.skirting, stockLengths: [3600, 2400] },
|
||||
},
|
||||
};
|
||||
expect(decodeState(rawLink(legacy))).toEqual({ state, error: null });
|
||||
expect(
|
||||
decodeState('?skirting.length=4800&skirting.stock=3600&skirting.stock=2400&done=skirting:S6'),
|
||||
).toEqual({ state, error: null });
|
||||
});
|
||||
|
||||
it.each([null, '2400', true, {}, [], NaN, Infinity])(
|
||||
'rejects invalid skirting board length %j',
|
||||
(stockLength) => {
|
||||
const state = defaultState();
|
||||
expectInvalid(
|
||||
rawLink({
|
||||
...state,
|
||||
project: { ...state.project, skirting: { ...state.project.skirting, stockLength } },
|
||||
}),
|
||||
/finite number/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['', 'NaN', 'Infinity', 'true', '0x10'])(
|
||||
'rejects invalid editable skirting length %s',
|
||||
(length) => {
|
||||
expectInvalid(`?skirting.length=${length}`, /finite number/);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects sparse, zero, malformed and excessive room, wall and door indices', () => {
|
||||
expectInvalid('?room.2.id=second', /consecutive/);
|
||||
expectInvalid('?room.0.id=zero', /positive whole numbers/);
|
||||
|
|
@ -345,7 +441,7 @@ describe('URL state', () => {
|
|||
it('converts a legacy shared plan to readable fields without losing inputs or selections', () => {
|
||||
const state = defaultState();
|
||||
state.project.rooms.push(fireplace('second', 'Fireplace room'));
|
||||
state.project.skirting.stockLengths = [2400, 3600];
|
||||
state.project.skirting.stockLength = 3600;
|
||||
state.view = { ...state.view, roomId: 'second', selectedPiece: 'B2' };
|
||||
const legacy = decodeState(rawLink(state));
|
||||
expect(legacy.error).toBeNull();
|
||||
|
|
@ -427,7 +523,7 @@ describe('URL state', () => {
|
|||
const state = defaultState();
|
||||
state.project.completedBoards = ['floor:B4', 'floor:B4'];
|
||||
expect(() => encodeState(state)).toThrow(/duplicate entries/);
|
||||
const distinct = decodeState('?done=floor:B1&done=skirting:S1');
|
||||
const distinct = decodeState('?skirting.length=2400&done=floor:B1&done=skirting:S1');
|
||||
expect(distinct.error).toBeNull();
|
||||
expect(distinct.state.project.completedBoards).toEqual(['floor:B1', 'skirting:S1']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue