498 lines
21 KiB
TypeScript
498 lines
21 KiB
TypeScript
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 = { stockLength: 2400, thickness: 18, minLength: 300, kerf: 3 };
|
|
const rectangle = (width = 4000, height = 3000, id = 'r1'): Room => ({
|
|
id,
|
|
name: id,
|
|
direction: 0,
|
|
walls: [width, height, width, height].map((length, index) => ({
|
|
id: `w${index}`,
|
|
length,
|
|
turn: 90,
|
|
doors: [],
|
|
})),
|
|
});
|
|
|
|
describe('skirting cutting plan', () => {
|
|
it('retains coping overlap in the wall-length blanks and preserves every visible run', () => {
|
|
const plan = planSkirting([rectangle()], settings, 5);
|
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
|
expect(plan.pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(14_000);
|
|
expect(plan.pieces.every((piece) => piece.length >= settings.minLength)).toBe(true);
|
|
expect(skirtingCuts(plan)[0].id).toBe('A1');
|
|
for (let wall = 0; wall < 4; wall++) {
|
|
const pieces = plan.pieces.filter((piece) => piece.wallIndex === wall);
|
|
expect(pieces[0].leftType).toBe(wall === 0 ? 'internal' : 'cope');
|
|
expect(pieces.at(-1)?.rightType).toBe(wall === 3 ? 'cope' : 'internal');
|
|
expect(pieces.every((piece) => piece.visibleLength >= settings.minLength - 1e-6)).toBe(true);
|
|
expect(pieces.reduce((sum, piece) => sum + piece.visibleLength, 0)).toBeCloseTo(
|
|
[4000, 3000, 4000, 3000][wall] - 36,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('removes doors and cuts square at each opening', () => {
|
|
const room = rectangle();
|
|
room.walls[0].doors = [{ id: 'd1', offset: 1000, width: 900 }];
|
|
const plan = planSkirting([room], settings, 5);
|
|
const pieces = plan.pieces.filter((piece) => piece.wallIndex === 0);
|
|
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(4000 - 900);
|
|
expect(
|
|
pieces.some((piece) => Math.abs(piece.end.x - 1000) < 1e-6 && piece.rightType === 'square'),
|
|
).toBe(true);
|
|
expect(
|
|
pieces.some((piece) => Math.abs(piece.start.x - 1900) < 1e-6 && piece.leftType === 'square'),
|
|
).toBe(true);
|
|
expect(
|
|
pieces.every((piece) => piece.end.x <= 1000 + 1e-6 || piece.start.x >= 1900 - 1e-6),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('does not silently produce undersized pieces next to doors', () => {
|
|
const room = rectangle();
|
|
room.walls[0].doors = [{ id: 'd1', offset: 200, width: 900 }];
|
|
const plan = planSkirting([room], settings, 5);
|
|
expect(plan.pieces).toEqual([]);
|
|
expect(
|
|
plan.issues.some((issue) => issue.severity === 'error' && issue.message.includes('minimum')),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('rejects crossing angled end cuts even when the back length meets the minimum', () => {
|
|
const room: Room = {
|
|
id: 'acute',
|
|
name: 'Acute corner',
|
|
direction: 0,
|
|
walls: [1000, 1000, 2000 * Math.sin((5 * Math.PI) / 180)].map((length, index) => ({
|
|
id: `w${index}`,
|
|
length,
|
|
turn: [170, 95, 95][index],
|
|
doors: index === 1 ? [{ id: 'd1', offset: 200, width: 300 }] : [],
|
|
})),
|
|
};
|
|
const plan = planSkirting([room], { ...settings, minLength: 50 }, 5);
|
|
expect(plan.pieces).toEqual([]);
|
|
expect(
|
|
plan.issues.some((issue) => issue.message.includes('shorter than the 50 mm minimum')),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('extends the long point at external fireplace corners and specifies 45° mitres', () => {
|
|
const room: Room = {
|
|
id: 'r1',
|
|
name: 'Fireplace',
|
|
direction: 0,
|
|
walls: [5000, 4000, 2000, 600, 1000, 600, 2000, 4000].map((length, index) => ({
|
|
id: `w${index}`,
|
|
length,
|
|
turn: [90, 90, 90, -90, -90, 90, 90, 90][index],
|
|
doors: [],
|
|
})),
|
|
};
|
|
const plan = planSkirting([room], { ...settings, stockLength: 6000 }, 5);
|
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
|
const front = plan.pieces.find((piece) => piece.wallIndex === 4)!;
|
|
expect(front.backLength).toBeCloseTo(1000);
|
|
expect(front.length).toBeCloseTo(1036);
|
|
expect(front.leftMitre).toBeCloseTo(45);
|
|
expect(front.rightMitre).toBeCloseTo(45);
|
|
expect(front.leftType).toBe('external');
|
|
|
|
expect(front.template.map((point) => Math.round(point.x))).toEqual([18, 1018, 1036, 0]);
|
|
const reversed = { ...room, walls: room.walls.map((wall) => ({ ...wall, turn: -wall.turn })) };
|
|
const reversePlan = planSkirting([reversed], { ...settings, stockLength: 6000 }, 5);
|
|
const reverseFront = reversePlan.pieces.find((piece) => piece.wallIndex === 4)!;
|
|
expect(reverseFront.length).toBeCloseTo(1036);
|
|
});
|
|
|
|
it('packs across rooms with deterministic unique IDs and accounts for kerf', () => {
|
|
const rooms = [rectangle(1800, 1800, 'r1'), rectangle(1800, 1800, 'r2')];
|
|
const options = { ...settings, stockLength: 1800, thickness: 0, kerf: 3 };
|
|
const plan = planSkirting(rooms, options, 5);
|
|
expect(plan).toEqual(planSkirting(rooms, options, 5));
|
|
expect(new Set(plan.pieces.map((piece) => piece.id)).size).toBe(plan.pieces.length);
|
|
expect(plan.pieces.every((piece) => piece.id.startsWith('SU'))).toBe(true);
|
|
expect(plan.stockCount).toBe(8);
|
|
expect(plan.cuts).toBe(0);
|
|
expect(plan.waste).toBeCloseTo(0);
|
|
const trimmed = planSkirting(
|
|
[rectangle(1199, 1199)],
|
|
{ ...settings, stockLength: 2400, thickness: 0, minLength: 1199 },
|
|
5,
|
|
);
|
|
expect(trimmed.stockCount).toBe(4); // 1199 + 3 + 1199 cannot fit into 2400.
|
|
expect(trimmed.cuts).toBe(4);
|
|
for (const stock of trimmed.stocks) expect(stock.used + stock.waste).toBeCloseTo(stock.length);
|
|
});
|
|
|
|
it('allows the final saw pass to extend beyond a short stock tail', () => {
|
|
const plan = planSkirting(
|
|
[rectangle(1199, 1199)],
|
|
{ ...settings, stockLength: 1200, thickness: 0 },
|
|
5,
|
|
);
|
|
expect(plan.pieces).toHaveLength(4);
|
|
expect(plan.stockCount).toBe(4);
|
|
expect(plan.waste).toBeCloseTo(4);
|
|
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('packs multiple cuts into the single purchased stock length', () => {
|
|
const plan = planSkirting(
|
|
[rectangle(1200, 1200)],
|
|
{ ...settings, stockLength: 2400, thickness: 0, kerf: 0 },
|
|
5,
|
|
);
|
|
expect(plan.stockCount).toBe(2);
|
|
expect(plan.stocks.every((stock) => stock.length === 2400)).toBe(true);
|
|
expect(plan.waste).toBeCloseTo(0);
|
|
});
|
|
|
|
it('discards rounded splits that exceed fractional stock while retaining a fitting candidate', () => {
|
|
const plan = planSkirting([rectangle(2400.1, 2400.1)], { ...settings, stockLength: 1200.2 }, 5);
|
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
|
expect(plan.pieces).toHaveLength(8);
|
|
for (let wall = 0; wall < 4; wall++) {
|
|
expect(
|
|
plan.pieces
|
|
.filter((piece) => piece.wallIndex === wall)
|
|
.reduce((sum, piece) => sum + piece.backLength, 0),
|
|
).toBeCloseTo(2400.1);
|
|
}
|
|
for (const stock of plan.stocks) {
|
|
let available = stock.length;
|
|
for (const id of stock.pieceIds) {
|
|
const piece = plan.pieces.find((piece) => piece.id === id)!;
|
|
const blank =
|
|
Math.abs(piece.length - available) < 1e-6 ? available : cutLength(piece.length);
|
|
expect(blank).toBeLessThanOrEqual(available + 1e-6);
|
|
expect(piece.blankLength).toBeCloseTo(blank);
|
|
expect(piece.visibleLength).toBeGreaterThanOrEqual(settings.minLength - 1e-6);
|
|
available = Math.max(0, available - blank - settings.kerf);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('adds a joint when fractional stock cannot supply the rounded two-piece split', () => {
|
|
const plan = planSkirting([rectangle(2400.3, 2400.3)], { ...settings, stockLength: 1200.2 }, 5);
|
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
|
expect(plan.pieces.length).toBeGreaterThan(8);
|
|
for (let wall = 0; wall < 4; wall++) {
|
|
expect(
|
|
plan.pieces
|
|
.filter((piece) => piece.wallIndex === wall)
|
|
.reduce((sum, piece) => sum + piece.backLength, 0),
|
|
).toBeCloseTo(2400.3);
|
|
}
|
|
});
|
|
|
|
it('keeps another room when rounding makes a minimum-sized split impossible', () => {
|
|
const valid = rectangle(2400.4, 2400.4, 'valid');
|
|
const invalid = rectangle(2400.1, 2400.1, 'invalid');
|
|
const options = { ...settings, stockLength: 1200.2, thickness: 0, minLength: 1200.05 };
|
|
const plan = planSkirting([valid, invalid], options, 5);
|
|
expect(plan.pieces).toEqual(planSkirting([valid], options, 5).pieces);
|
|
expect(plan.pieces).toHaveLength(8);
|
|
const failures = plan.issues.filter((issue) => issue.severity === 'error');
|
|
expect(failures.length).toBeGreaterThan(0);
|
|
expect(failures.every((issue) => issue.roomId === invalid.id)).toBe(true);
|
|
});
|
|
|
|
it('prefers one fewer saw pass over saving a stock board on a square room', () => {
|
|
const room = rectangle(4000, 4000);
|
|
const plan = planSkirting([room], settings, 5);
|
|
expect(plan).toEqual(planSkirting([room], settings, 5));
|
|
expect(plan.stockCount).toBe(8); // Seven boards require thirteen preparation/coping passes.
|
|
expect(plan.cuts).toBe(12);
|
|
expect(plan.waste).toBeCloseTo(8 * 2400 - 16000);
|
|
for (let wallIndex = 0; wallIndex < 4; wallIndex++) {
|
|
const pieces = plan.pieces.filter((piece) => piece.wallIndex === wallIndex);
|
|
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(4000);
|
|
pieces.slice(1).forEach((piece, index) => {
|
|
expect(piece.start.x).toBeCloseTo(pieces[index].end.x);
|
|
expect(piece.start.y).toBeCloseTo(pieces[index].end.y);
|
|
});
|
|
}
|
|
for (const stock of plan.stocks) {
|
|
let available = stock.length;
|
|
for (const id of stock.pieceIds) {
|
|
const piece = plan.pieces.find((piece) => piece.id === id)!;
|
|
expect(piece.length).toBeLessThanOrEqual(available + 1e-6);
|
|
expect(piece.backLength).toBeGreaterThanOrEqual(settings.minLength - 1e-6);
|
|
available = Math.max(0, available - piece.length - settings.kerf);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('reserves the shorter front edge when splitting a run at angled internal corners', () => {
|
|
const room: Room = {
|
|
id: 'triangle',
|
|
name: 'Triangle',
|
|
direction: 0,
|
|
walls: [0, 1, 2].map((index) => ({
|
|
id: `w${index}`,
|
|
length: 3100,
|
|
turn: 120,
|
|
doors: [],
|
|
})),
|
|
};
|
|
const plan = planSkirting([room], { ...settings, stockLength: 340 }, 5);
|
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
|
expect(plan.pieces).toHaveLength(30);
|
|
for (const piece of plan.pieces) {
|
|
expect(piece.length).toBeLessThanOrEqual(340 + 1e-6);
|
|
expect(piece.backLength).toBeGreaterThanOrEqual(300 - 1e-6);
|
|
expect(piece.template[2].x - piece.template[3].x).toBeGreaterThanOrEqual(300 - 1e-6);
|
|
expect(piece.visibleLength).toBeGreaterThanOrEqual(300 - 1e-6);
|
|
}
|
|
for (let wallIndex = 0; wallIndex < 3; wallIndex++) {
|
|
expect(
|
|
plan.pieces
|
|
.filter((piece) => piece.wallIndex === wallIndex)
|
|
.reduce((sum, piece) => sum + piece.backLength, 0),
|
|
).toBeCloseTo(3100);
|
|
}
|
|
});
|
|
|
|
it('prepares oblique copes at half-turn angles and wall-fits their receiving ends', () => {
|
|
const room: Room = {
|
|
id: 'triangle',
|
|
name: 'Triangle',
|
|
direction: 0,
|
|
walls: [0, 1, 2].map((index) => ({
|
|
id: `w${index}`,
|
|
length: 3000,
|
|
turn: 120,
|
|
doors: [],
|
|
})),
|
|
};
|
|
const plan = planSkirting([room], { ...settings, stockLength: 4000 }, 5);
|
|
expect(plan.pieces).toHaveLength(3);
|
|
expect(plan.pieces.every((piece) => Math.abs(piece.backLength - 3000) < 1e-6)).toBe(true);
|
|
for (const piece of plan.pieces) {
|
|
expect(piece.leftMitre).toBeCloseTo(piece.wallIndex === 0 ? 30 : 60);
|
|
expect(piece.rightMitre).toBeCloseTo(piece.wallIndex === 2 ? 60 : 30);
|
|
expect(piece.leftType).toBe(piece.wallIndex === 0 ? 'internal' : 'cope');
|
|
expect(piece.visibleLength).toBeCloseTo(3000 - 36 * Math.tan(Math.PI / 3));
|
|
expect(piece.installedTemplate[1].x - piece.installedTemplate[0].x).toBeGreaterThan(
|
|
settings.minLength,
|
|
);
|
|
}
|
|
expect(plan.cuts).toBe(12); // Three blank crosscuts, six preparation bevels, three copes.
|
|
});
|
|
|
|
it('can leave a minimum-sized final piece without making a tiny join', () => {
|
|
const plan = planSkirting([rectangle(2500, 2000)], { ...settings, thickness: 0 }, 5);
|
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
|
expect(plan.pieces.every((piece) => piece.backLength >= 300 - 1e-6)).toBe(true);
|
|
for (const wall of [0, 2])
|
|
expect(
|
|
plan.pieces
|
|
.filter((piece) => piece.wallIndex === wall)
|
|
.reduce((sum, piece) => sum + piece.backLength, 0),
|
|
).toBeCloseTo(2500);
|
|
});
|
|
|
|
it('refuses invalid outlines and stock settings', () => {
|
|
const room = rectangle();
|
|
room.walls[3].length += 100;
|
|
expect(planSkirting([room], settings, 5).pieces).toEqual([]);
|
|
expect(planSkirting([rectangle()], { ...settings, stockLength: 0 }, 5).issues[0].severity).toBe(
|
|
'error',
|
|
);
|
|
expect(
|
|
planSkirting([rectangle()], { ...settings, minLength: 1e-10 }, 5).issues[0].severity,
|
|
).toBe('error');
|
|
});
|
|
|
|
it('numbers visible cuts continuously per room and remaps stock references', () => {
|
|
const rooms = [rectangle(4000, 3000, 'one'), rectangle(4000, 3000, 'two')];
|
|
const plan = planSkirting(rooms, settings, 5);
|
|
const cuts = skirtingCuts(plan);
|
|
expect(plan.pieces.length).toBeGreaterThan(cuts.length);
|
|
for (const [roomIndex, room] of rooms.entries()) {
|
|
const ids = cuts.filter((piece) => piece.roomId === room.id).map((piece) => piece.id);
|
|
expect(ids.length).toBeGreaterThan(0);
|
|
expect(ids).toEqual(ids.map((_, index) => `${roomIndex === 0 ? 'A' : 'B'}${index + 1}`));
|
|
}
|
|
const ids = plan.pieces.map((piece) => piece.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
expect(plan.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual([...ids].sort());
|
|
expect(plan).toEqual(planSkirting(rooms, settings, 5));
|
|
});
|
|
|
|
it('keeps valid rooms when another room has an undersized run or unclosed outline', () => {
|
|
const valid = rectangle(4000, 3000, 'valid');
|
|
const small = rectangle(4000, 3000, 'small');
|
|
small.walls[0].doors = [{ id: 'door', offset: 200, width: 900 }];
|
|
const open = rectangle(4000, 3000, 'open');
|
|
open.walls[0].length += 100;
|
|
const plan = planSkirting([valid, small, open], settings, 5);
|
|
expect(plan.pieces.length).toBeGreaterThan(0);
|
|
expect(plan.pieces.every((piece) => piece.roomId === valid.id)).toBe(true);
|
|
expect(
|
|
plan.issues.filter((issue) => issue.severity === 'error').map((issue) => issue.roomId),
|
|
).toEqual(['small', 'open']);
|
|
expect(plan.stockCount).toBe(planSkirting([valid], settings, 5).stockCount);
|
|
expect(
|
|
planSkirting([valid], { ...settings, stockLength: 0 }, 5).issues[0].roomId,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('attributes corner stock-fit failures to the affected room', () => {
|
|
const valid = rectangle(4100, 4100, 'valid');
|
|
valid.walls.forEach((wall) => {
|
|
wall.doors = [{ id: `${wall.id}-door`, offset: 0, width: 100 }];
|
|
});
|
|
const triangle: Room = {
|
|
id: 'triangle',
|
|
name: 'Triangle',
|
|
direction: 0,
|
|
walls: [0, 1, 2].map((index) => ({ id: `w${index}`, length: 3000, turn: 120, doors: [] })),
|
|
};
|
|
const plan = planSkirting(
|
|
[valid, triangle],
|
|
{ ...settings, stockLength: 500, minLength: 490 },
|
|
5,
|
|
);
|
|
expect(plan.pieces.length).toBeGreaterThan(0);
|
|
expect(plan.pieces.every((piece) => piece.roomId === valid.id)).toBe(true);
|
|
const failures = plan.issues.filter((issue) => issue.severity === 'error');
|
|
expect(failures.length).toBeGreaterThan(0);
|
|
expect(
|
|
failures.every(
|
|
(issue) => issue.roomId === triangle.id && issue.message.includes('stock lengths'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
it('counts full-stock copes as cuts and uses a receiving first wall to close the installation order', () => {
|
|
const plan = planSkirting([rectangle(2400, 2400)], settings, 1);
|
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
|
expect(plan.stockCount).toBe(4);
|
|
expect(plan.cuts).toBe(8); // Four preparation mitres and four profile-coping operations.
|
|
for (const piece of plan.pieces) expect(piece.length).toBeCloseTo(2400);
|
|
expect(plan.pieces.map((piece) => [piece.leftType, piece.rightType])).toEqual([
|
|
['internal', 'internal'],
|
|
['cope', 'internal'],
|
|
['cope', 'internal'],
|
|
['cope', 'cope'],
|
|
]);
|
|
expect(skirtingCuts(plan).map((piece) => piece.id)).toEqual(['A1', 'A2', 'A3']);
|
|
expect(plan.pieces[0].id).toMatch(/^SU/);
|
|
for (const piece of plan.pieces) {
|
|
expect(piece.visibleLength).toBeCloseTo(2364);
|
|
}
|
|
const two = planSkirting(
|
|
[rectangle(2400, 2400, 'one'), rectangle(2400, 2400, 'two')],
|
|
settings,
|
|
1,
|
|
);
|
|
const ids = two.pieces.map((piece) => piece.id);
|
|
expect(new Set(ids).size).toBe(8);
|
|
expect(two.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual([...ids].sort());
|
|
});
|
|
|
|
it('does not cope across a door opening that removes the receiving corner', () => {
|
|
const r = rectangle(2400, 2400);
|
|
r.walls[0].doors = [{ id: 'corner-door', offset: 0, width: 900 }];
|
|
const plan = planSkirting([r], settings, 1);
|
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
|
expect(plan.pieces.filter((piece) => piece.wallIndex === 0)[0].leftType).toBe('square');
|
|
expect(plan.pieces.filter((piece) => piece.wallIndex === 3).at(-1)!.rightType).toBe('square');
|
|
expect(
|
|
plan.pieces.reduce(
|
|
(sum, piece) =>
|
|
sum + Number(piece.leftType === 'cope') + Number(piece.rightType === 'cope'),
|
|
0,
|
|
),
|
|
).toBe(3);
|
|
});
|
|
|
|
it('rejects an exposed run shorter than the minimum even when the preparation blank is long enough', () => {
|
|
const plan = planSkirting([rectangle(330, 2400)], settings, 1);
|
|
expect(plan.pieces).toEqual([]); // 330 mm blank, but only 330 - 2*18 = 294 mm is visible.
|
|
expect(
|
|
plan.issues.some((issue) => issue.severity === 'error' && issue.message.includes('minimum')),
|
|
).toBe(true);
|
|
});
|
|
|
|
it.each([1, -1])(
|
|
'reserves oblique overlap at obtuse internal corners (orientation %i)',
|
|
(orientation) => {
|
|
const r: Room = {
|
|
id: 'hexagon',
|
|
name: 'Hexagon',
|
|
direction: 0,
|
|
walls: Array.from({ length: 6 }, (_, index) => ({
|
|
id: `w${index}`,
|
|
length: 1000.2,
|
|
turn: 60 * orientation,
|
|
doors: [],
|
|
})),
|
|
};
|
|
const plan = planSkirting([r], { ...settings, stockLength: 2400 }, 1);
|
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
|
const allowance = 18 / Math.tan(Math.PI / 3);
|
|
expect(plan.pieces[0].length).toBeCloseTo(1000.2 + 2 * allowance);
|
|
expect(plan.pieces.at(-1)!.length).toBeCloseTo(1000.2);
|
|
expect(plan.pieces.at(-1)!.leftMitre).toBeCloseTo(30);
|
|
expect(plan.pieces.at(-1)!.rightMitre).toBeCloseTo(30);
|
|
for (const piece of plan.pieces)
|
|
expect(piece.visibleLength).toBeCloseTo(1000.2 - 36 * Math.tan(Math.PI / 6));
|
|
for (const stock of plan.stocks) {
|
|
const pieces = stock.pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
|
|
expect(
|
|
pieces.reduce((sum, piece) => sum + cutLength(piece.length), 0) +
|
|
(pieces.length - 1) * settings.kerf,
|
|
).toBeLessThanOrEqual(stock.length);
|
|
}
|
|
expect(plan).toEqual(planSkirting([r], { ...settings, stockLength: 2400 }, 1));
|
|
},
|
|
);
|
|
it('counts a finishing crosscut when the rounded blank uses the complete available stock', () => {
|
|
const plan = planSkirting(
|
|
[rectangle(1199.6, 1199.6)],
|
|
{ ...settings, stockLength: 1200, thickness: 0 },
|
|
1,
|
|
);
|
|
expect(plan.stockCount).toBe(4);
|
|
expect(plan.cuts).toBe(4);
|
|
expect(skirtingCuts(plan)).toHaveLength(4);
|
|
expect(plan.pieces.every((piece) => cutLength(piece.length) === 1200)).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('uses the allocator cut decision at fractional stock boundaries', () => {
|
|
const room = rectangle(999.995, 999.995, 'precision');
|
|
room.walls.forEach((wall) => {
|
|
wall.doors = [];
|
|
});
|
|
const plan = planSkirting(
|
|
[room],
|
|
{ stockLength: 1000, thickness: 0, minLength: 100, kerf: 3 },
|
|
1,
|
|
);
|
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
|
expect(plan.cuts).toBe(4);
|
|
expect(skirtingCuts(plan)).toHaveLength(4);
|
|
});
|