floor-planner/tests/cut-preview.test.ts
2026-09-11 16:58:58 +01:00

253 lines
9.4 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 { defaultProject } from '../src/lib/defaults';
import { dimensionedOutline, flooringTemplate, 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,
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('keeps precise geometry while rounding displayed cuts up to half millimetres', () => {
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('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('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('45° external');
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');
});
});