This commit is contained in:
Andras Schmelczer 2026-09-11 16:58:58 +01:00
commit 62a385e4c9
33 changed files with 2649 additions and 809 deletions

View file

@ -0,0 +1,200 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { defaultProject, rectangle } from '../src/lib/defaults';
import { boardSearchLabel, findBoardMatches } from '../src/lib/board-tracking';
import { CutList } from '../src/components/CutList';
import type { FlooringPiece, FlooringPlan, SkirtingPiece, SkirtingPlan } from '../src/lib/types';
const project = defaultProject();
project.rooms.push(rectangle('room-2', 'Office'));
function floorPiece(id: string, stockId: string, roomId = 'room-1', cut = true): FlooringPiece {
return {
id,
stockId,
roomId,
cut,
length: cut ? 400 : 1200,
width: 190,
row: 1,
column: 1,
polygon: [],
template: [],
shaped: false,
reused: false,
};
}
const flooring: FlooringPlan = {
pieces: [
floorPiece('F1', 'B1'),
floorPiece('F12', 'B1', 'room-2'),
floorPiece('F10', 'B10'),
floorPiece('FU1', 'B3', 'room-2', false),
],
rows: [],
stocks: [
{ id: 'B1', pieceIds: ['F1', 'F12'], remaining: 394 },
{ id: 'B10', pieceIds: ['F10'], remaining: 797 },
{ id: 'B3', pieceIds: ['FU1'], remaining: 0 },
],
boardCount: 3,
cuts: 3,
wasteArea: 0,
wastePercent: 0,
reusedCount: 0,
coveredArea: 0,
issues: [],
};
function skirtPiece(id: string, stockId: string, roomId = 'room-1', length = 900): SkirtingPiece {
return {
id,
stockId,
roomId,
length,
backLength: length,
wallId: 'w1',
wallIndex: 0,
start: { x: 0, y: 0 },
end: { x: length, y: 0 },
leftMitre: 0,
rightMitre: 0,
leftType: 'square',
rightType: 'square',
};
}
const skirting: SkirtingPlan = {
pieces: [
skirtPiece('A2', 'S6'),
skirtPiece('B1', 'S6', 'room-2', 1497),
skirtPiece('B10', 'S60'),
skirtPiece('SU1', 'S7', 'room-2', 2400),
],
stocks: [
{ id: 'S6', length: 2400, used: 2397, waste: 3, pieceIds: ['A2', 'B1'] },
{ id: 'S60', length: 2400, used: 900, waste: 1500, pieceIds: ['B10'] },
{ id: 'S7', length: 2400, used: 2400, waste: 0, pieceIds: ['SU1'] },
],
stockCount: 3,
cuts: 3,
waste: 1503,
issues: [],
};
const find = (query: string) => findBoardMatches(project, flooring, skirting, query);
const noop = () => {};
function list(search = '', completedBoards: string[] = []) {
return renderToStaticMarkup(
createElement(CutList, {
project,
flooring,
skirting,
select: noop,
search,
setSearch: noop,
completedBoards,
toggleBoard: noop,
locateBoard: noop,
locatePiece: noop,
}),
);
}
describe('board tracking search', () => {
it('matches cut and stock codes exactly instead of confusing F1 with F10/F12 or B1 with B10', () => {
expect(find(' F1 ')).toEqual({
pieceIds: ['F1'],
roomIds: ['room-1'],
boardKeys: ['floor:B1'],
});
expect(find('b1')).toEqual({
pieceIds: ['F1', 'F12', 'B1'],
roomIds: ['room-1', 'room-2'],
boardKeys: ['floor:B1', 'skirting:S6'],
});
expect(find('S6').pieceIds).toEqual(['A2', 'B1']);
expect(find('B10').pieceIds).toEqual(['F10', 'B10']);
});
it('uses material-qualified source keys for unambiguous board location and includes all its rooms', () => {
expect(find(' FLOOR:B1 ')).toEqual({
pieceIds: ['F1', 'F12'],
roomIds: ['room-1', 'room-2'],
boardKeys: ['floor:B1'],
});
expect(find('skirting:S6')).toEqual({
pieceIds: ['A2', 'B1'],
roomIds: ['room-1', 'room-2'],
boardKeys: ['skirting:S6'],
});
});
it('finds full uncut boards as well as cuts, and searches room names without case sensitivity', () => {
expect(find('b3')).toEqual({ pieceIds: ['FU1'], roomIds: ['room-2'], boardKeys: ['floor:B3'] });
expect(find('s7')).toEqual({
pieceIds: ['SU1'],
roomIds: ['room-2'],
boardKeys: ['skirting:S7'],
});
expect(find(' OfFiCe ')).toEqual({
pieceIds: ['F12', 'FU1', 'B1', 'SU1'],
roomIds: ['room-2'],
boardKeys: ['floor:B1', 'floor:B3', 'skirting:S6', 'skirting:S7'],
});
expect(find('living').roomIds).toEqual(['room-1']);
});
it('returns empty matches for empty or unknown queries and never mutates the plans', () => {
const before = structuredClone({ project, flooring, skirting });
expect(find(' ')).toEqual({ pieceIds: [], roomIds: [], boardKeys: [] });
expect(find('missing')).toEqual({ pieceIds: [], roomIds: [], boardKeys: [] });
expect(find('B1')).toEqual(find('B1'));
expect({ project, flooring, skirting }).toEqual(before);
});
it('keeps all cuts from a matching source together and applies completion to the whole group', () => {
const markup = list('F1', ['floor:B1']);
expect(markup).toContain('data-board-key="floor:B1"');
expect(markup).not.toContain('data-board-key="floor:B10"');
expect(markup).not.toContain('data-board-key="skirting:S6"');
expect(markup).toContain('Preview flooring cut F1');
expect(markup).toContain('Preview flooring cut F12');
expect(markup.match(/class="board-cut is-done/g)).toHaveLength(2);
expect(markup).toContain('aria-label="Mark cuts F1 + F12 done" checked=""');
expect(markup).toContain('Show cuts F1 + F12 on plan');
expect(markup).toContain('Show cut F12 on plan');
expect(markup).toContain('<span>Done</span>');
});
it('names board groups and actions using their cuts while keeping bookkeeping IDs internal', () => {
const markup = list();
expect(markup).toContain('aria-label="Flooring cuts F1 + F12"');
expect(markup).toContain('aria-label="Skirting cuts A2 + B1"');
expect(markup).toContain('Mark cuts A2 + B1 done');
expect(markup).toContain('data-board-key="skirting:S6"');
expect(markup).toContain('Find a cut or room');
expect(markup).not.toMatch(/(?:Floorboard B\d|Skirting board S\d|Export CSV)/i);
expect(markup).not.toMatch(/aria-label="[^"]*(?:floor:B\d|skirting:S\d)/i);
});
it('shows cut names when a located board is stored as a qualified source key', () => {
expect(boardSearchLabel(project, flooring, skirting, 'floor:B1')).toBe('F1 + F12');
expect(boardSearchLabel(project, flooring, skirting, 'skirting:S6')).toBe('A2 + B1');
expect(boardSearchLabel(project, flooring, skirting, 'Office')).toBe('Office');
expect(list('floor:B1')).toContain('value="F1 + F12"');
expect(list('skirting:S6')).toContain('value="A2 + B1"');
expect(find('F1 + F12')).toEqual(find('floor:B1'));
// B1 here is a skirting cut, not the hidden flooring stock with the same internal code.
expect(find('A2 + B1')).toEqual(find('skirting:S6'));
});
it('exposes an interactive full-board result only when searched, without inventing a cut preview', () => {
expect(list()).not.toContain('data-board-key="floor:B3"');
const markup = list('floor:B3');
expect(markup).toContain('data-board-key="floor:B3"');
expect(markup).toContain('Mark uncut floorboard done');
expect(markup).toContain('Show uncut floorboard on plan');
expect(markup).toContain('Installed uncut');
expect(markup).toContain('value="Uncut floorboard"');
expect(markup).not.toContain('Preview flooring cut FU1');
expect(list('skirting:S7')).toContain('value="Uncut skirting board"');
});
});

View file

@ -4,12 +4,8 @@ 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,
exactMm,
flooringTemplate,
skirtingEndBoxes,
} from '../src/lib/cut-preview';
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 = {
@ -50,7 +46,7 @@ const skirt: SkirtingPiece = {
};
describe('cut shape previews', () => {
it('draws rectangular floor cuts too, keeping their precise measured dimensions', () => {
it('keeps precise geometry while rounding displayed cuts up to half millimetres', () => {
const points = flooringTemplate(floor);
expect(points).toEqual([
{ x: 0, y: 0 },
@ -61,11 +57,11 @@ describe('cut shape previews', () => {
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.001 × 190 mm');
expect(markup).toContain('>600.001</text>');
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(exactMm(600.001)).toBe('600.001');
expect(cutMm(600.001)).toBe('600.5');
});
it('keeps skirting endpoints in original coordinates and gives both ends equal square windows', () => {
@ -166,9 +162,9 @@ describe('cut shape previews', () => {
expect(preview.dimensions[1].text).toBe('200');
const angle = preview.dimensions[1].angle!;
expect(angle).toBeCloseTo(53.130102, 6);
const markedAngle = (Number(exactMm(angle)) * Math.PI) / 180;
expect(200 * Math.cos(markedAngle)).toBeCloseTo(120, 2);
expect(200 * Math.sin(markedAngle)).toBeCloseTo(160, 2);
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());
@ -181,7 +177,7 @@ describe('cut shape previews', () => {
piece: { ...floor, length: 920, width: 160, shaped: true, template: points },
}),
);
expect(markup).toContain('>53.13°</tspan>');
expect(markup).toContain('>53.1°</tspan>');
expect(markup).toContain('angles from horizontal');
});

View file

@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import { fireplace } from '../src/lib/defaults';
import { buildGeometry } from '../src/lib/geometry';
import { dimensionLabels } from '../src/lib/dimension-labels';
import { dimensionLabels, wallDimensionBox } from '../src/lib/dimension-labels';
import fixture from './fixtures/angled-room.json';
import type { Room } from '../src/lib/types';
describe('wall dimensions', () => {
it('keeps fireplace recess measurements apart without changing measured lengths', () => {
const geometry = buildGeometry(fireplace('room-1', 'Fireplace'), 10);
@ -19,3 +21,32 @@ describe('wall dimensions', () => {
expect(dimensionLabels(geometry.edges, 4800, 1)).toEqual(labels);
});
});
// Regression for the supplied 12-wall plan: W12's outward line crossed the old viewport.
it('includes the complete diagonal W12 dimension, not only its midpoint label', () => {
const room = fixture.project.rooms[0] as Room;
const geometry = buildGeometry(room, fixture.project.tolerance);
const xs = geometry.vertices.map((p) => p.x),
ys = geometry.vertices.map((p) => p.y);
const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
const labels = dimensionLabels(geometry.edges, span, 1);
const edge = geometry.edges[11],
label = labels[11];
const box = wallDimensionBox(edge, label, span);
expect(label.text).toBe('W12 · 6,097');
const end = {
x: edge.end.x + label.normal.x * label.offset,
y: edge.end.y + label.normal.y * label.offset,
};
expect(end.x).toBeLessThan(Math.min(...xs));
expect(box.minX).toBeLessThan(end.x);
expect(box.maxX).toBeGreaterThan(label.box.maxX - 1e-6);
for (const point of [edge.start, edge.end]) {
const x = point.x + label.normal.x * label.offset,
y = point.y + label.normal.y * label.offset;
expect(x).toBeGreaterThanOrEqual(box.minX);
expect(x).toBeLessThanOrEqual(box.maxX);
expect(y).toBeGreaterThanOrEqual(box.minY);
expect(y).toBeLessThanOrEqual(box.maxY);
}
});

View file

@ -1,179 +0,0 @@
import { describe, expect, it } from 'vitest';
import { defaultProject } from '../src/lib/defaults';
import { cutListCsv } from '../src/lib/export';
import { flooringCuts, isSkirtingCut, uncutFlooring, uncutSkirting } from '../src/lib/cut-list';
import type { FlooringPlan, SkirtingPlan } from '../src/lib/types';
const floor: FlooringPlan = {
pieces: [
{
id: 'F1',
roomId: 'room-1',
row: 1,
column: 1,
polygon: [],
length: 450,
width: 190,
stockId: 'B1',
reused: true,
cut: true,
shaped: true,
template: [
{ x: 0, y: 0 },
{ x: 450, y: 0 },
{ x: 450, y: 190 },
{ x: 0, y: 100 },
],
},
],
rows: [],
stocks: [{ id: 'B1', pieceIds: ['F1'], remaining: 747 }],
boardCount: 1,
cuts: 2,
wasteArea: 0,
wastePercent: 0,
reusedCount: 1,
coveredArea: 65250,
issues: [{ severity: 'warning', message: 'Check the shaped edge.' }],
};
const skirt: SkirtingPlan = {
pieces: [
{
id: 'A1',
roomId: 'room-1',
wallId: 'room-1-w1',
wallIndex: 0,
start: { x: 0, y: 0 },
end: { x: 1500, y: 0 },
length: 1518,
backLength: 1500,
leftMitre: 0,
rightMitre: 45,
leftType: 'square',
rightType: 'external',
stockId: 'S1',
},
],
stocks: [{ id: 'S1', length: 2400, used: 1521, waste: 879, pieceIds: ['A1'] }],
stockCount: 1,
cuts: 1,
waste: 879,
issues: [],
};
describe('cut list CSV', () => {
it('exports measured cuts, shape coordinates, mitres, board references and purchase counts', () => {
const csv = cutListCsv(defaultProject(), floor, skirt);
expect(csv.startsWith('\uFEFF"Material"')).toBe(true);
expect(csv).toContain('"Flooring","Living room","F1","B1","450","190"');
expect(csv).toContain('Reused offcut; Shaped cut');
expect(csv).toContain('0.0:0.0 | 450.0:0.0 | 450.0:190.0 | 0.0:100.0');
expect(csv).toContain('"Skirting","Living room","A1","S1","1518","18","0","45"');
expect(csv).toContain('Back length 1500.0 mm; left square; right external');
expect(csv).toContain('Boards to buy; 1 installed pieces; 2 saw cuts');
expect(csv).not.toContain('Check the shaped edge.');
expect(csv.split('\r\n')).toHaveLength(6);
});
it('counts uncut full boards and lengths without exporting their codes or source allocations', () => {
const project = defaultProject();
const wholeFloor = {
...floor.pieces[0],
id: 'FULL-F',
stockId: 'FULL-B',
length: 1200,
width: 190,
cut: false,
shaped: false,
reused: false,
};
const readyOffcut = {
...floor.pieces[0],
id: 'READY-F',
length: 747,
cut: false,
shaped: false,
};
const floorPlan: FlooringPlan = {
...floor,
boardCount: 2,
pieces: [...floor.pieces, wholeFloor, readyOffcut],
stocks: [
{ ...floor.stocks[0], pieceIds: ['F1', 'READY-F'], remaining: 0 },
{ id: 'FULL-B', pieceIds: ['FULL-F'], remaining: 0 },
],
};
const wholeSkirt = {
...skirt.pieces[0],
id: 'FULL-A',
stockId: 'FULL-S',
length: 2400,
backLength: 2400,
leftMitre: 0,
rightMitre: 0,
leftType: 'square' as const,
rightType: 'square' as const,
};
const skirtPlan: SkirtingPlan = {
...skirt,
stockCount: 2,
pieces: [...skirt.pieces, wholeSkirt],
stocks: [
...skirt.stocks,
{ id: 'FULL-S', length: 2400, used: 2400, waste: 0, pieceIds: ['FULL-A'] },
],
};
const csv = cutListCsv(project, floorPlan, skirtPlan);
for (const code of ['FULL-F', 'FULL-B', 'FULL-A', 'FULL-S']) expect(csv).not.toContain(code);
expect(csv).toContain('"Flooring uncut boards","","","","1200","190","","","1"');
expect(csv).toContain('"Flooring ready offcuts","","","","","","","","1"');
expect(csv).toContain('"Skirting uncut lengths","","","","2400","18","","","1"');
expect(csv).toContain('"Flooring total","","","","1200","190","","","2"');
expect(csv).toContain('"Skirting total","","","","","18","","","2"');
expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring",'))).toHaveLength(2);
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting",'))).toHaveLength(1);
expect(csv.split('\r\n').filter((row) => row.startsWith('"Flooring stock",'))).toHaveLength(0);
expect(csv.split('\r\n').filter((row) => row.startsWith('"Skirting stock",'))).toHaveLength(0);
expect(flooringCuts(floorPlan)).toEqual([...floor.pieces, readyOffcut]);
expect(csv).toContain('"Flooring","Living room","READY-F","B1","747","190"');
expect(csv).toContain('Reused offcut; Use offcut; no new cut');
expect(csv).not.toContain('"Flooring stock"');
expect(uncutFlooring(floorPlan, project.flooring)).toEqual({ wholeBoards: 1, readyOffcuts: 1 });
expect(uncutSkirting(skirtPlan)).toEqual([{ length: 2400, count: 1 }]);
});
it('keeps full-length mitred pieces and shortened square skirting in the cut list', () => {
const mitred = { ...skirt.pieces[0], length: 2400 };
const shortened = { ...skirt.pieces[0], leftMitre: 0, rightMitre: 0 };
const uncut = { ...shortened, length: 2400 };
expect(isSkirtingCut(mitred, skirt)).toBe(true);
expect(isSkirtingCut(shortened, skirt)).toBe(true);
expect(isSkirtingCut(uncut, skirt)).toBe(false);
expect(isSkirtingCut({ ...uncut, stockId: 'missing' }, skirt)).toBe(true);
const csv = cutListCsv(defaultProject(), floor, { ...skirt, pieces: [mitred] });
expect(csv).toContain('"Skirting","Living room","A1","S1","2400","18","0","45"');
});
it('quotes commas, double quotes, newlines and Unicode room names', () => {
const project = defaultProject();
project.rooms[0].name = 'Café 北, "main"\nroom';
const csv = cutListCsv(project, floor, skirt);
expect(csv).toContain('"Café 北, ""main""\nroom"');
});
it.each(['=SUM(1,1)', '+cmd', '-formula', '@formula'])(
'neutralizes spreadsheet formulas in user text %s',
(name) => {
const project = defaultProject();
project.rooms[0].name = name;
expect(cutListCsv(project, floor, skirt)).toContain(`"'${name}"`);
},
);
it('produces identical output for identical input without mutating the plans', () => {
const project = defaultProject();
const before = structuredClone({ project, floor, skirt });
expect(cutListCsv(project, floor, skirt)).toBe(cutListCsv(project, floor, skirt));
expect({ project, floor, skirt }).toEqual(before);
});
});

132
tests/fixtures/angled-room-f8.json vendored Normal file
View file

@ -0,0 +1,132 @@
{
"project": {
"version": 1,
"name": "My flooring project",
"rooms": [
{
"id": "room-2",
"name": "Room 2",
"walls": [
{
"id": "room-2-w1",
"length": 4800,
"turn": 10,
"doors": []
},
{
"id": "room-2-w2",
"length": 3600,
"turn": 90,
"doors": []
},
{
"id": "room-2-w3",
"length": 1650,
"turn": 90,
"doors": []
},
{
"id": "room-2-w4",
"length": 450,
"turn": -90,
"doors": []
},
{
"id": "room-2-w5",
"length": 1500,
"turn": -90,
"doors": []
},
{
"id": "room-2-w6",
"length": 450,
"turn": 90,
"doors": []
},
{
"id": "room-2-w8",
"length": 3600,
"turn": 90,
"doors": [
{
"id": "room-2-door-1",
"offset": 500,
"width": 826
}
]
},
{
"id": "room-2-w7",
"length": 1650,
"turn": 90,
"doors": []
},
{
"id": "room-2-w-3",
"length": 1000,
"turn": -72.12516087806887,
"doors": []
},
{
"id": "room-2-w-1",
"length": 1000,
"turn": 93.2,
"doors": []
},
{
"id": "room-2-w-2",
"length": 1000,
"turn": -91.7,
"doors": []
},
{
"id": "room-2-w-4",
"length": 6097,
"turn": 59.23323117818302,
"doors": [
{
"id": "room-2-door-2",
"offset": 500,
"width": 826
}
]
},
{
"id": "room-2-w-5",
"length": 1687,
"turn": 90,
"doors": []
}
],
"direction": 90
}
],
"flooring": {
"length": 1200,
"width": 190,
"gap": 10,
"pattern": "random",
"minOffset": 0.1,
"maxOffset": 1000,
"minLength": 0.1,
"minWidth": 0.1,
"kerf": 3
},
"skirting": {
"stockLengths": [
2400
],
"thickness": 27,
"minLength": 200,
"kerf": 3
},
"tolerance": 50,
"completedBoards": []
},
"view": {
"roomId": "room-2",
"selectedPiece": "F8",
"search": "F8",
"collapsedSections": []
}
}

115
tests/fixtures/angled-room.json vendored Normal file
View file

@ -0,0 +1,115 @@
{
"project": {
"version": 1,
"name": "My flooring project",
"rooms": [
{
"id": "room-2",
"name": "Room 2",
"walls": [
{
"id": "room-2-w1",
"length": 4800,
"turn": 10,
"doors": []
},
{
"id": "room-2-w2",
"length": 3600,
"turn": 90,
"doors": []
},
{
"id": "room-2-w3",
"length": 1650,
"turn": 90,
"doors": []
},
{
"id": "room-2-w4",
"length": 450,
"turn": -90,
"doors": []
},
{
"id": "room-2-w5",
"length": 1500,
"turn": -90,
"doors": []
},
{
"id": "room-2-w6",
"length": 450,
"turn": 90,
"doors": []
},
{
"id": "room-2-w7",
"length": 1650,
"turn": 90,
"doors": []
},
{
"id": "room-2-w8",
"length": 3600,
"turn": 90,
"doors": [
{
"id": "room-2-door-1",
"offset": 500,
"width": 826
}
]
},
{
"id": "room-2-w-1",
"length": 1000,
"turn": 93.2,
"doors": []
},
{
"id": "room-2-w-2",
"length": 1000,
"turn": -91.7,
"doors": []
},
{
"id": "room-2-w-3",
"length": 1000,
"turn": -72.12516087806887,
"doors": []
},
{
"id": "room-2-w-4",
"length": 6097,
"turn": 90,
"doors": []
}
],
"direction": 90
}
],
"flooring": {
"length": 1200,
"width": 190,
"gap": 10,
"pattern": "random",
"minOffset": 0.1,
"maxOffset": 1000,
"minLength": 0.1,
"minWidth": 0.1,
"kerf": 3
},
"skirting": {
"stockLengths": [2400],
"thickness": 27,
"minLength": 200,
"kerf": 3
},
"tolerance": 50
},
"view": {
"roomId": "room-2",
"selectedPiece": null
}
}

View file

@ -1,10 +1,11 @@
import f8Fixture from './fixtures/angled-room-f8.json';
import { describe, expect, it } from 'vitest';
import ClipperLib from 'clipper-lib';
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 type { FlooringPlan, FlooringSettings, Point, Room } from '../src/lib/types';
import type { FlooringPlan, FlooringSettings, Point, Project, Room } from '../src/lib/types';
const settings: FlooringSettings = {
length: 1200,
@ -367,3 +368,5 @@ describe('laminate planning', () => {
expect(errors(planFlooring([valid], { ...settings, width: 0 }, 1))[0].roomId).toBeUndefined();
});
});
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)}));});

18
tests/format.test.ts Normal file
View file

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { cutMm } from '../src/lib/format';
describe('cut dimensions', () => {
it.each([
[0, '0'],
[89.423, '89.5'],
[177.163, '177.5'],
[488.648, '489'],
[545.948, '546'],
[600.5, '600.5'],
[600.50000000001, '600.5'],
[1200, '1,200'],
[NaN, '—'],
])('rounds %s upward to a useful half-millimetre mark', (length, shown) => {
expect(cutMm(length)).toBe(shown);
});
});

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { planSkirting } from '../src/lib/skirting';
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 };
@ -146,6 +147,22 @@ describe('skirting cutting plan', () => {
expect(plan.cuts).toBe(4);
});
it('reserves the displayed half-millimetre blanks so rounded cuts fit their source', () => {
const plan = planSkirting(
[rectangle(1198.4, 1198.6)],
{ ...settings, thickness: 0, minLength: 1100 },
5,
);
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
expect(plan.stockCount).toBe(3);
for (const stock of plan.stocks) {
const pieces = stock.pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
const marked = pieces.reduce((sum, piece) => sum + cutLength(piece.length), 0);
expect(marked + (pieces.length - 1) * settings.kerf).toBeLessThanOrEqual(stock.length);
}
expect(plan.pieces.reduce((sum, piece) => sum + piece.length, 0)).toBeCloseTo(4794);
});
it('looks ahead when choosing from different stock lengths', () => {
const plan = planSkirting(
[rectangle(1200, 1200)],

View file

@ -45,7 +45,7 @@ describe('URL state', () => {
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.view = { roomId: 'room-2', selectedPiece: 'A12' };
state.view = { ...state.view, roomId: 'room-2', selectedPiece: 'A12' };
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
});
@ -64,8 +64,13 @@ describe('URL state', () => {
const decoded = decodeState(rawLink(old));
expect(decoded.error).toBeNull();
expect(decoded.state.project).toEqual(state.project);
expect(decoded.state.view).toEqual({ roomId: state.view.roomId, selectedPiece: 'A4' });
expect(Object.keys(decoded.state.view).sort()).toEqual(['roomId', 'selectedPiece']);
expect(decoded.state.view).toEqual({ ...state.view, selectedPiece: 'A4' });
expect(Object.keys(decoded.state.view).sort()).toEqual([
'collapsedSections',
'roomId',
'search',
'selectedPiece',
]);
});
it('canonicalizes property order and discards derived or unknown fields', () => {
@ -341,7 +346,7 @@ describe('URL state', () => {
const state = defaultState();
state.project.rooms.push(fireplace('second', 'Fireplace room'));
state.project.skirting.stockLengths = [2400, 3600];
state.view = { roomId: 'second', selectedPiece: 'B2' };
state.view = { ...state.view, roomId: 'second', selectedPiece: 'B2' };
const legacy = decodeState(rawLink(state));
expect(legacy.error).toBeNull();
const query = encodeState(legacy.state);
@ -350,6 +355,124 @@ describe('URL state', () => {
expect(decodeState(query)).toEqual({ state, error: null });
});
it('stores progress and view preferences as optional readable fields', () => {
const state = defaultState();
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
state.view.search = 'F12';
state.view.collapsedSections = ['materials', 'measurements'];
const query = encodeState(state);
expect(query).toContain('done=floor:B4&done=skirting:S6');
expect(query).toContain('search=F12&collapsed=materials&collapsed=measurements');
expect(decodeState(query)).toEqual({ state, error: null });
const empty = new URLSearchParams(encodeState(defaultState()));
for (const key of ['done', 'search', 'collapsed']) expect(empty.has(key)).toBe(false);
});
it('canonicalizes completion and collapse sets without mutating their inputs', () => {
const state = defaultState();
state.project.completedBoards = ['skirting:S6', 'floor:B4'];
state.view.collapsedSections = ['measurements', 'cuts', 'materials'];
const before = structuredClone(state);
const query = encodeState(state);
const loaded = decodeState(query);
expect(loaded.error).toBeNull();
expect(loaded.state.project.completedBoards).toEqual(['floor:B4', 'skirting:S6']);
expect(loaded.state.view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']);
expect(state).toEqual(before);
state.project.completedBoards.reverse();
state.view.collapsedSections.reverse();
expect(encodeState(state)).toBe(query);
expect(encodeState(loaded.state)).toBe(query);
});
it('loads old query and fragment links with empty progress and view preferences', () => {
const state = defaultState();
const old = {
project: { ...state.project, completedBoards: undefined },
view: { roomId: state.view.roomId, selectedPiece: null },
};
expect(decodeState(rawLink(old))).toEqual({ state, error: null });
expect(decodeState('?flooring.length=1200').state).toEqual(state);
const first = defaultState();
first.project.completedBoards.push('floor:B4');
first.view.collapsedSections.push('materials');
expect(defaultState().project.completedBoards).toEqual([]);
expect(defaultState().view.collapsedSections).toEqual([]);
});
it('preserves Unicode and spaces in the search input independently of selected cuts', () => {
const state = defaultState();
state.view.search = ' Café 北 F12 / A2 ';
state.view.selectedPiece = 'F12';
expect(decodeState(encodeState(state))).toEqual({ state, error: null });
expectInvalid('?search=F1&search=F2', /only once/);
});
it.each([
'',
'B4',
'floor:S4',
'skirting:B4',
'floor:B0',
'floor:B04',
'floor:B-1',
'floor:B1.5',
])('rejects invalid completed-board key %s', (key) => {
expectInvalid(`?done=${encodeURIComponent(key)}`, /floor:B1 or skirting:S1/);
});
it('rejects duplicate completion and collapse entries rather than counting them twice', () => {
expectInvalid('?done=floor:B4&done=floor:B4', /duplicate entries/);
expectInvalid('?collapsed=materials&collapsed=materials', /duplicate entries/);
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');
expect(distinct.error).toBeNull();
expect(distinct.state.project.completedBoards).toEqual(['floor:B1', 'skirting:S1']);
});
it('bounds the search, progress list and collapsed sections', () => {
expectInvalid(`?search=${'a'.repeat(URL_LIMITS.searchLength + 1)}`, /at most 200 characters/);
expectInvalid('?collapsed=outline', /unsupported value/);
expectInvalid(
'?collapsed=cuts&collapsed=materials&collapsed=measurements&collapsed=cuts',
/at most 3/,
);
const state = defaultState();
state.project.completedBoards = Array.from(
{ length: URL_LIMITS.completedBoards + 1 },
(_, index) => `floor:B${index + 1}`,
);
expect(() => encodeState(state)).toThrow(/at most 12000/);
expectInvalid(rawLink(state), /at most 12000/);
expectInvalid(
`?done=floor:B${'1'.repeat(URL_LIMITS.identifierLength)}`,
/at most 120 characters/,
);
});
it.each([null, true, 'floor:B4', [false], [{}]])(
'rejects incorrectly typed completed-board sets %j',
(completedBoards) => {
const state = defaultState();
expectInvalid(rawLink({ ...state, project: { ...state.project, completedBoards } }));
},
);
it.each([null, 42, [], {}])('rejects incorrectly typed search input %j', (search) => {
const state = defaultState();
expectInvalid(rawLink({ ...state, view: { ...state.view, search } }));
});
it.each([null, true, 'materials', [42], ['outline']])(
'rejects incorrectly typed or unsupported collapsed sections %j',
(collapsedSections) => {
const state = defaultState();
expectInvalid(rawLink({ ...state, view: { ...state.view, collapsedSections } }));
},
);
it('rejects oversized query strings before parsing fields', () => {
expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/);
});

View file

@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { rectangle } from '../src/lib/defaults';
import { insertWallAfter, moveWall } from '../src/lib/wall-editing';
const ids = (room: ReturnType<typeof rectangle>) => room.walls.map((wall) => wall.id);
describe('inserting walls', () => {
it.each([0, 1, 3])(
'inserts after wall index %i without changing existing measurements or doors',
(index) => {
const room = rectangle();
const original = structuredClone(room);
const inserted = insertWallAfter(room, room.walls[index].id);
expect(inserted.walls).toHaveLength(room.walls.length + 1);
expect(inserted.walls[index + 1]).toMatchObject({ length: 1000, turn: 90, doors: [] });
expect(new Set(ids(inserted)).size).toBe(inserted.walls.length);
expect(inserted.walls.filter((wall) => room.walls.includes(wall))).toEqual(room.walls);
for (const wall of room.walls)
expect(inserted.walls.find((candidate) => candidate.id === wall.id)).toBe(wall);
expect(room).toEqual(original);
expect(inserted.id).toBe(room.id);
expect(inserted.name).toBe(room.name);
expect(inserted.direction).toBe(room.direction);
},
);
it('assigns deterministic, unique IDs when repeatedly inserting in the same position', () => {
const room = rectangle();
const first = insertWallAfter(room, room.walls[0].id);
const second = insertWallAfter(first, room.walls[0].id);
expect(insertWallAfter(room, room.walls[0].id)).toEqual(first);
expect(new Set(ids(second)).size).toBe(6);
expect(second.walls[2]).toBe(first.walls[1]);
});
it('ignores an unknown wall and the wall-count limit without creating replacement state', () => {
const room = rectangle();
expect(insertWallAfter(room, 'missing')).toBe(room);
const full = {
...room,
walls: Array.from({ length: 100 }, (_, i) => ({ ...room.walls[0], id: `wall-${i}` })),
};
expect(insertWallAfter(full, 'wall-3')).toBe(full);
});
});
describe('reordering walls', () => {
it.each([
[0, 3],
[3, 0],
[1, 2],
[2, 1],
])(
'moves index %i to index %i while preserving all walls and their attached doors',
(from, to) => {
const room = rectangle();
const original = structuredClone(room);
const moved = moveWall(room, room.walls[from].id, to);
expect(moved.walls[to]).toBe(room.walls[from]);
expect([...ids(moved)].sort()).toEqual([...ids(room)].sort());
for (const wall of room.walls) {
const result = moved.walls.find((candidate) => candidate.id === wall.id)!;
expect(result).toBe(wall);
expect(result.doors).toBe(wall.doors);
}
expect(room).toEqual(original);
expect(moveWall(moved, room.walls[from].id, from).walls).toEqual(room.walls);
},
);
it('clamps destinations and treats missing, unchanged or non-finite positions as no-ops', () => {
const room = rectangle();
expect(moveWall(room, room.walls[0].id, -10)).toBe(room);
expect(moveWall(room, room.walls[0].id, 100).walls.at(-1)).toBe(room.walls[0]);
expect(moveWall(room, room.walls[3].id, -10).walls[0]).toBe(room.walls[3]);
expect(moveWall(room, 'missing', 2)).toBe(room);
expect(moveWall(room, room.walls[1].id, 1)).toBe(room);
expect(moveWall(room, room.walls[1].id, Number.NaN)).toBe(room);
});
});