import { describe, expect, it } from 'vitest'; import { planSkirting } from '../src/lib/skirting'; import { skirtingCuts } from '../src/lib/cut-list'; import type { Room, SkirtingSettings } from '../src/lib/types'; const settings: SkirtingSettings = { stockLengths: [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('subtracts internal butt thickness and preserves every installed wall 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 - 4 * 18, ); 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('internal'); expect(pieces.at(-1)?.rightType).toBe('internal'); expect( pieces.every( (piece) => Math.abs(piece.leftMitre) < 1e-6 && Math.abs(piece.rightMitre) < 1e-6, ), ).toBe(true); } }); 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 - 18); 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, stockLengths: [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, stockLengths: [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, stockLengths: [1800, 3600], 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, stockLengths: [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, stockLengths: [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('looks ahead when choosing from different stock lengths', () => { const plan = planSkirting( [rectangle(1200, 1200)], { ...settings, stockLengths: [1800, 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('lets reused tails set wall joints to save a stock length without extra saw cuts', () => { 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(4); expect(plan.waste).toBeCloseTo(7 * 2400 - (16000 - 4 * 18)); expect(plan.pieces.some((piece) => Math.abs(piece.length - 815) < 1e-6)).toBe(true); 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(3982); 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, stockLengths: [311] }, 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(311 + 1e-6); expect(piece.backLength).toBeGreaterThanOrEqual(300 - 1e-6); expect(piece.template![2].x - piece.template![3].x).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 - 18 / Math.sin((120 * Math.PI) / 180)); } }); it('calculates angled butt cuts and back setbacks for non-square internal corners', () => { 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, stockLengths: [4000] }, 5); expect(plan.pieces).toHaveLength(3); expect(plan.pieces[0].backLength).toBeCloseTo(3000 - 18 / Math.sin((120 * Math.PI) / 180)); for (const piece of plan.pieces) { expect(piece.leftMitre).toBeCloseTo(30); expect(piece.rightMitre).toBeCloseTo(30); expect(piece.leftType).toBe('internal'); expect(piece.leftBevel).toBeCloseTo(30); expect(piece.rightBevel).toBeCloseTo(-30); expect(piece.template![2].x - piece.template![3].x).toBeGreaterThan(settings.minLength); } expect(plan.cuts).toBe(9); // Three blank crosscuts and six end bevels. }); 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, stockLengths: [] }, 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, stockLengths: [] }, 5).issues[0].roomId, ).toBeUndefined(); }); it('attributes corner stock-fit failures to the affected room', () => { const valid = rectangle(4000, 3000, 'valid'); 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, stockLengths: [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); }); });