floor-planner/tests/cut-preview.test.ts
2026-09-11 22:14:24 +01:00

351 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createElement } from 'react';
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, skirtingEndBoxes } from '../src/lib/cut-preview';
import { cutMm, degree } from '../src/lib/format';
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,
blankLength: 600.5,
blankWidth: 190,
stockId: 'B1',
reused: false,
cut: true,
shaped: false,
template: [
{ x: 0, y: 0 },
{ x: 600.001, y: 0 },
{ x: 600.001, y: 190 },
{ x: 0, y: 190 },
],
factoryLeft: false,
factoryRight: false,
ripEdge: null,
};
const template: SkirtingPiece['template'] = [
{ 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,
blankLength: 1200,
backLength: 1164,
leftMitre: 45,
rightMitre: 45,
leftType: 'external',
rightType: 'external',
stockId: 'S1',
template,
installedTemplate: template,
visibleLength: 1200,
leftOverlap: 0,
rightOverlap: 0,
leftBevel: -45,
rightBevel: 45,
};
describe('cut shape previews', () => {
it('keeps precise geometry while rounding displayed cuts up to half millimetres', () => {
const points = floor.template;
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('600.5 × 190 mm');
expect(markup).toContain('>600.5</text>');
expect(markup).toContain('>190</text>');
expect(markup.match(/class="dimension-label"/g)).toHaveLength(2);
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,
template: [
{ x: 0, y: 0 },
{ x: 597.2, y: 0 },
{ x: 597.2, y: 190.1 },
{ x: 0, y: 190.1 },
],
reused: true,
};
const markup = renderToStaticMarkup(createElement(FlooringPreview, { piece }));
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);
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('1,200 × 18 mm');
expect(markup).toContain('>1,164</text>');
expect(markup).toContain('>1,200</text>');
expect(markup).not.toContain('exaggerat');
});
it('labels every edge of a 320 by 100 mm notch without collisions or tiny mobile text', () => {
const notch = [
{ x: 0, y: 0 },
{ x: 880, y: 0 },
{ x: 880, y: 100 },
{ x: 1200, y: 100 },
{ x: 1200, y: 190 },
{ x: 0, y: 190 },
];
const original = structuredClone(notch);
const preview = dimensionedOutline(notch);
expect(preview.dimensions.map((dimension) => dimension.length)).toEqual([
880, 100, 320, 90, 1200, 190,
]);
expect((preview.font * 280) / preview.box.width).toBeGreaterThanOrEqual(12);
preview.dimensions.forEach((dimension, i) => {
const box = dimension.labelBox;
expect(box.x).toBeGreaterThan(preview.box.x);
expect(box.y).toBeGreaterThan(preview.box.y);
expect(box.x + box.width).toBeLessThan(preview.box.x + preview.box.width);
expect(box.y + box.height).toBeLessThan(preview.box.y + preview.box.height);
const projection = (point: Point) =>
point.x * dimension.normal.x + point.y * dimension.normal.y;
const nearestLabel = Math.min(
...[
{ x: box.x, y: box.y },
{ x: box.x + box.width, y: box.y },
{ x: box.x, y: box.y + box.height },
{ x: box.x + box.width, y: box.y + box.height },
].map(projection),
);
expect(nearestLabel, 'Dimension text stays outside the shape').toBeGreaterThan(
Math.max(...notch.map(projection)),
);
for (const other of preview.dimensions.slice(i + 1)) {
const b = other.labelBox;
expect(
box.x + box.width <= b.x ||
b.x + b.width <= box.x ||
box.y + box.height <= b.y ||
b.y + b.height <= box.y,
).toBe(true);
}
});
expect(notch).toEqual(original);
expect(dimensionedOutline(notch)).toEqual(preview);
const markup = renderToStaticMarkup(
createElement(FlooringPreview, {
piece: { ...floor, length: 1200, shaped: true, template: notch },
}),
);
expect(markup).toContain('>320</text>');
expect(markup).toContain('>100</text>');
expect(markup).not.toMatch(/>[1-6]<\/text>/);
});
it('measures angled edges along the cut and retains the original outline', () => {
const points = [
{ x: 0, y: 0 },
{ x: 800, y: 0 },
{ x: 920, y: 160 },
{ x: 0, y: 160 },
];
const preview = dimensionedOutline(points);
expect((preview.font * 280) / preview.box.width).toBeGreaterThanOrEqual(12);
expect(preview.dimensions[1].length).toBe(200);
expect(preview.dimensions[1].text).toBe('200');
const angle = preview.dimensions[1].angle!;
expect(angle).toBeCloseTo(53.130102, 6);
const markedAngle = (Number(degree(angle).replace('°', '')) * Math.PI) / 180;
expect(Math.abs(200 * Math.cos(markedAngle) - 120)).toBeLessThan(0.5);
expect(Math.abs(200 * Math.sin(markedAngle) - 160)).toBeLessThan(0.5);
expect(preview.dimensions[1].start).toEqual({ x: 800, y: 0 });
expect(preview.dimensions[1].end).toEqual({ x: 920, y: 160 });
const reversed = dimensionedOutline([...points].reverse());
expect(reversed.dimensions.map((dimension) => dimension.length).sort()).toEqual(
preview.dimensions.map((dimension) => dimension.length).sort(),
);
expect(preview.dimensions.filter((dimension) => dimension.angle !== undefined)).toHaveLength(1);
const markup = renderToStaticMarkup(
createElement(FlooringPreview, {
piece: { ...floor, length: 920, width: 160, shaped: true, template: points },
}),
);
expect(markup).toContain('>53.1°</tspan>');
expect(markup).toContain('angles from horizontal');
});
it('keeps dialog instructions useful without source codes or numbered coordinate lists', () => {
const markup = renderToStaticMarkup(
createElement(CutDialog, {
floor: { ...floor, factoryLeft: true, ripEdge: 'top' },
project: defaultProject(),
close: () => {},
}),
);
expect(markup).toContain('Living room · Row 1');
expect(markup).toContain('Keep the left factory end; trim from the right.');
expect(markup).toContain('Rip the top long edge; keep the bottom factory edge.');
expect(markup).not.toContain('Source');
expect(markup).not.toContain('coordinates');
expect(markup).not.toContain('cut-edge-lengths');
});
it('shows skirting dimensions and mitre directions without a second measurement list', () => {
const markup = renderToStaticMarkup(
createElement(CutDialog, {
skirt,
project: defaultProject(),
close: () => {},
}),
);
expect(markup).toContain('Living room · Wall 1');
expect(markup).toContain('>1,164</text>');
expect(markup).toContain('>1,200</text>');
expect(markup).toContain('Mitre 45°');
expect(markup).not.toContain('Source');
expect(markup).not.toContain('coordinates');
expect(markup).not.toContain('<dl');
});
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');
});
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);
}
});
});