..
This commit is contained in:
parent
9689bfcc18
commit
62a385e4c9
33 changed files with 2649 additions and 809 deletions
200
tests/board-tracking.test.ts
Normal file
200
tests/board-tracking.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
132
tests/fixtures/angled-room-f8.json
vendored
Normal 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
115
tests/fixtures/angled-room.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
18
tests/format.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
80
tests/wall-editing.test.ts
Normal file
80
tests/wall-editing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue