import { describe, expect, it } from 'vitest'; import { plannerCases } from '../benchmarks/cases'; import { compareFlooring, compareSkirting, flooringMetrics, skirtingMetrics, } from '../benchmarks/metrics'; import baseline from '../benchmarks/baseline.json'; import { planFlooringOptions } from '../src/lib/flooring'; import { planSkirting } from '../src/lib/skirting'; import { defaultProject, rectangle } from '../src/lib/defaults'; import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry'; import type { FlooringPlan, Project } from '../src/lib/types'; function validateFlooring(plan: FlooringPlan, project: Project) { const s = project.flooring; expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]); const byId = new Map(plan.pieces.map((piece) => [piece.id, piece])); expect(byId.size).toBe(plan.pieces.length); const ids = [...byId.keys()].sort(); expect(plan.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual(ids); expect(plan.rows.flatMap((row) => row.pieceIds).sort()).toEqual(ids); let crosscuts = 0; let reused = 0; for (const stock of plan.stocks) { let remaining = s.length; let left = true; let right = true; stock.pieceIds.forEach((id, index) => { const piece = byId.get(id)!; expect(piece.stockId).toBe(stock.id); expect(piece.blankLength).toBeLessThanOrEqual(remaining + 0.01); expect(!piece.factoryLeft || left).toBe(true); expect(!piece.factoryRight || right).toBe(true); expect(piece.reused).toBe(index > 0); reused += Number(piece.reused); const cut = remaining - piece.length > 0.01; crosscuts += Number(cut); expect(piece.cut).toBe(cut || piece.shaped || !!piece.ripEdge); if (cut) { remaining = Math.round(Math.max(0, remaining - piece.blankLength - s.kerf) * 1000) / 1000; if (piece.factoryRight) right = false; else left = false; } else { remaining = 0; left = right = false; } }); expect(stock.remaining).toBeCloseTo(remaining, 3); } const metrics = flooringMetrics(plan, s); expect(plan.cuts).toBe(crosscuts + metrics.shapingCuts + metrics.ripCuts); expect(plan.reusedCount).toBe(reused); for (const room of project.rooms) { const pieces = plan.pieces.filter((piece) => piece.roomId === room.id); const paths = insetPolygon(buildGeometry(room, project.tolerance).vertices, s.gap); const expected = paths.reduce((sum, path) => sum + Math.abs(signedArea(path)), 0); const actual = pieces.reduce((sum, piece) => sum + Math.abs(signedArea(piece.polygon)), 0); // Integer clipping rounds intersections to 0.001 mm; bound area drift by boundary length. const perimeter = paths.reduce( (sum, path) => sum + path.reduce((length, p, i) => { const q = path[(i + 1) % path.length]; return length + Math.hypot(p.x - q.x, p.y - q.y); }, 0), 0, ); expect(Math.abs(actual - expected)).toBeLessThan(Math.max(1, perimeter * 0.001)); const rows = plan.rows.filter((row) => row.roomId === room.id); let previousEnd = -Infinity; rows.forEach((row, index) => { const ys = row.pieceIds.flatMap((id) => byId.get(id)!.polygon.map((p) => (room.direction === 0 ? p.y : -p.x)), ); expect(Math.min(...ys)).toBeGreaterThanOrEqual(previousEnd - 0.01); previousEnd = Math.max(...ys); for (const id of row.pieceIds) { const piece = byId.get(id)!; expect(piece.row).toBe(row.index); expect(piece.roomId).toBe(room.id); expect(piece.length).toBeGreaterThanOrEqual(s.minLength - 0.01); expect(piece.width).toBeGreaterThanOrEqual(s.minWidth - 0.01); } if (!index) return; const delta = Math.abs(row.offset - rows[index - 1].offset); const offset = Math.min(delta, s.length - delta); expect(offset).toBeGreaterThanOrEqual(s.minOffset - 0.01); expect(offset).toBeLessThanOrEqual(s.maxOffset + 0.01); }); } } describe('measured optimiser quality', () => { const cases = plannerCases(); const results = new Map< string, { options: ReturnType; skirt: ReturnType } >(); const resultFor = ({ name, project }: (typeof cases)[number]) => { let result = results.get(name); if (!result) { result = { options: planFlooringOptions(project.rooms, project.flooring, project.tolerance), skirt: skirtingMetrics(planSkirting(project.rooms, project.skirting, project.tolerance)), }; results.set(name, result); } return result; }; it.each(cases)( '$name preserves coverage, stock, factory ends and priority', ({ name, project }) => { const input = JSON.stringify(project); const { options, skirt } = resultFor({ name, project }); const cuts = flooringMetrics(options.cuts, project.flooring); const random = flooringMetrics(options.random, project.flooring); const before = baseline.find((row) => row.name === name)!; expect(compareFlooring(cuts, before.flooring)).toBeLessThanOrEqual(0); expect(compareFlooring(cuts, random)).toBeLessThanOrEqual(0); expect(compareFlooring(random, cuts, true)).toBeLessThanOrEqual(0); validateFlooring(options.cuts, project); if (options.random !== options.cuts) validateFlooring(options.random, project); expect(compareSkirting(skirt, before.skirting)).toBeLessThanOrEqual(0); expect(skirt.errors).toBe(0); expect(JSON.stringify(project)).toBe(input); }, ); it('retains the measured improvements across the complete fixed corpus', () => { const totals = { cuts: 0, randomCuts: 0, pattern: 0, skirtingCuts: 0 }; for (const c of cases) { const { options, skirt } = resultFor(c); totals.cuts += options.cuts.cuts; totals.randomCuts += options.random.cuts; totals.pattern += flooringMetrics(options.random, c.project.flooring).patternPenalty; totals.skirtingCuts += skirt.cuts; } expect(totals.cuts).toBeLessThanOrEqual(1741); // Original: 2177. expect(totals.randomCuts).toBeLessThanOrEqual(1994); expect(totals.pattern).toBeLessThanOrEqual(113); // Original: 265. expect(totals.skirtingCuts).toBeLessThanOrEqual(1436); // Original: 1447. }); it('packs reserved offcuts before applying the 2000-board cap on a 500-row floor', () => { const project = defaultProject(); project.flooring.pattern = 'brick'; project.rooms = [rectangle('limit', 'Limit', 4520, 95020)]; const plan = planFlooringOptions(project.rooms, project.flooring, 1).cuts; expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]); expect(plan.rows).toHaveLength(500); expect(plan.boardCount).toBe(2000); expect(plan.cuts).toBe(750); expect(plan.coveredArea).toBe(4500 * 95000); }); });