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.leftBevel).toBeCloseTo(-45); expect(front.rightBevel).toBeCloseTo(45); 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); expect(reverseFront.leftBevel).toBeCloseTo(-45); expect(reverseFront.rightBevel).toBeCloseTo(45); }); 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('lets reused tails set wall joints while counting preparation and coping operations', () => { const room = rectangle(4000, 4000); const plan = planSkirting([room], settings, 5); expect(plan).toEqual(planSkirting([room], settings, 5)); expect(plan.stockCount).toBe(7); // Fixed joints used eight lengths for this simple square. expect(plan.cuts).toBe(13); expect(plan.waste).toBeCloseTo(7 * 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); expect(piece.leftOverlap).toBeCloseTo(18); expect(piece.rightOverlap).toBeCloseTo(18); } 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); }); });