floor-planner/tests/cut-preview.test.ts
Andras Schmelczer 8bf3e3b705
Some checks failed
Deploy to Pages / build (push) Failing after 10m45s
Simplify
2026-09-11 23:27:29 +01:00

282 lines
10 KiB
TypeScript
Raw Permalink 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 { 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,
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 = {
cut: true,
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,
};
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 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('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();
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);
});
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);
}
});
});