282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|