import test from 'node:test'; import assert from 'node:assert/strict'; import { addUpperBlockAboveHorizontalSpan, getModuleVerticalBoundariesMm, resizeHorizontalSpanUpperBlock, resolveHorizontalSpan, validateHorizontalSpans, usesBlockConstruction, columnUsesBlockConstruction, horizontalConstructionRuns, getAxiloPlateCentersMm, getAxiloLateralCenterInsetMm, getAxiloPlinthFrontSideDeductionMm, PLINTH } from './manufacturing.js'; import { validateConfig } from '../server/services/configValidation.js'; import { generateCuttingList } from '../server/services/cuttingEngine.js'; function makeConfig(columnCount, spanHeightMm = 900) { const bottomMm = 300; const topMm = bottomMm + spanHeightMm; const bodyMm = 1800; const modules = Array.from({ length: columnCount }, (_, index) => ({ id: `col-${index + 1}`, type: 'penderie', moduleCode: 'penderie', widthMm: 600, minWidth: 300, maxWidth: 1200, shelves: 0, drawers: 0, hasRod: false, blockHeightsMm: [bottomMm, spanHeightMm, bodyMm - topMm], blockBacks: [true, index % 2 === 0, false], blockDoors: [true, false, true], hasBack: true, hasBackTop: false })); return { installationType: 'free', doorType: 'hinged', widthMm: columnCount * 600, heightMm: 1900, depthMm: 600, plinthHeightMm: 100, effectiveDrawerDepthMm: 600, hardware: { drawerType: 'visible', handleBoreHeightMm: 1000 }, modules, horizontalSpans: [{ id: 'span-main', moduleIds: modules.map((module) => module.id), bottomMm, topMm, hasBack: true, doorMode: 'none', type: 'open' }] }; } function assertSynchronizedUpperBlock(config, expectedSpanTopMm, expectedUpperHeightMm) { const resolved = resolveHorizontalSpan(config.horizontalSpans[0], config); assert.equal(resolved.topMm, expectedSpanTopMm); for (const module of config.modules) { const boundaries = getModuleVerticalBoundariesMm(module, config); assert.ok(boundaries.includes(expectedSpanTopMm)); assert.ok(boundaries.includes(1200)); const upperIndex = boundaries.indexOf(expectedSpanTopMm); assert.equal(boundaries[upperIndex + 1] - boundaries[upperIndex], expectedUpperHeightMm); assert.equal(module.blockDoors[upperIndex], true); resolved.coveredBlocks .find((covered) => covered.moduleId === module.id) .blockIndexes .forEach((blockIndex) => assert.equal(module.blockDoors[blockIndex], false)); assert.equal(module.blockHeightsMm.reduce((sum, height) => sum + height, 0), 1800); } } for (const columnCount of [2, 3]) { test(`${columnCount}-column span adds and resizes a synchronized upper block`, () => { const original = makeConfig(columnCount); const added = addUpperBlockAboveHorizontalSpan(original, 'span-main'); assert.equal(added.ok, true); assert.notEqual(added.config, original); assert.deepEqual(original.modules[0].blockHeightsMm, [300, 900, 600]); assertSynchronizedUpperBlock(added.config, 900, 300); const resized = resizeHorizontalSpanUpperBlock(added.config, 'span-main', 450); assert.equal(resized.ok, true); assertSynchronizedUpperBlock(resized.config, 750, 450); assert.deepEqual(validateHorizontalSpans(resized.config), []); assert.deepEqual(validateConfig(resized.config).errors, []); const cuttingList = generateCuttingList(resized.config, {}, resized.config.modules); assert.ok(Array.isArray(cuttingList.pieces)); assert.ok(cuttingList.pieces.length > 0); }); } test('too-short span rejects upper block without mutation', () => { const config = makeConfig(2, 500); const snapshot = structuredClone(config); const result = addUpperBlockAboveHorizontalSpan(config, 'span-main'); assert.equal(result.ok, false); assert.equal(result.code, 'span_too_short'); assert.deepEqual(config, snapshot); }); test('resize rejects non-step heights without mutation', () => { const added = addUpperBlockAboveHorizontalSpan(makeConfig(2), 'span-main'); assert.equal(added.ok, true); const snapshot = structuredClone(added.config); const result = resizeHorizontalSpanUpperBlock(added.config, 'span-main', 425); assert.equal(result.ok, false); assert.equal(result.code, 'invalid_height'); assert.deepEqual(added.config, snapshot); }); test('construction: dos → block ; sinon côté ou rayonnage → block', () => { assert.equal(usesBlockConstruction({ type: 'penderie' }, true), true); assert.equal(usesBlockConstruction({ type: 'penderie' }, false), false); assert.equal(usesBlockConstruction({ type: 'tiroirs' }, false), false); assert.equal(usesBlockConstruction({ type: 'rayonnage' }, false), true); assert.equal(usesBlockConstruction({ moduleCode: 'rayonnage' }, false), true); assert.equal(usesBlockConstruction({ type: 'penderie', shelves: 1 }, false), false); assert.equal(usesBlockConstruction({ type: 'penderie', shelves: 4 }, false), true); assert.equal(usesBlockConstruction({ type: 'mixte', shelves: 2 }, false), true); const penderie = { type: 'penderie', shelves: 1, hasBack: false, blockBacks: [false] }; const rayonnage = { type: 'rayonnage', hasBack: false, blockBacks: [false] }; const shelvedPenderie = { type: 'penderie', shelves: 4, hasBack: false, blockBacks: [false] }; const withDos = { type: 'penderie', hasBack: true, blockBacks: [true] }; const config = { hasBack: false }; assert.equal(columnUsesBlockConstruction(penderie, config), false); assert.equal(columnUsesBlockConstruction(rayonnage, config), true); assert.equal(columnUsesBlockConstruction(shelvedPenderie, config), true); assert.equal(columnUsesBlockConstruction(withDos, config), true); const modules = [ { type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] }, { type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] }, { type: 'rayonnage', widthMm: 500, hasBack: false, blockBacks: [false] }, { type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] } ]; assert.deepEqual( horizontalConstructionRuns(modules, config, 'top').map(({ start, end, isBlock }) => ({ start, end, isBlock })), [ { start: 0, end: 1, isBlock: false }, { start: 2, end: 2, isBlock: true }, { start: 3, end: 3, isBlock: false } ] ); const allPenderie = modules.map((m, i) => ( i === 2 ? { ...m, type: 'penderie', moduleCode: 'penderie', shelves: 0 } : m )); assert.deepEqual( horizontalConstructionRuns(allPenderie, config, 'top').map(({ start, end, isBlock }) => ({ start, end, isBlock })), [{ start: 0, end: 3, isBlock: false }] ); }); test('horizontal block at shell top replaces per-column roofs with one panel', () => { const modules = [1, 2, 3].map((index) => ({ id: `col-${index}`, type: 'rayonnage', widthMm: 600, blockHeightsMm: [1800], blockBacks: [false] })); const config = { widthMm: 1800, heightMm: 1900, plinthHeightMm: 100, modules, horizontalSpans: [{ id: 'span-top', moduleIds: ['col-1', 'col-2'], bottomMm: 0, topMm: 1800, hasBack: false, doorMode: 'none', type: 'open' }] }; assert.deepEqual( horizontalConstructionRuns(modules, config, 'top') .map(({ start, end, horizontalSpanId }) => ({ start, end, horizontalSpanId })), [ { start: 0, end: 1, horizontalSpanId: 'span-top' }, { start: 2, end: 2, horizontalSpanId: undefined } ] ); }); test('cutting list: merged dessus for separations, split for rayonnage', () => { const baseModule = (index, type) => ({ id: `col-${index + 1}`, type, moduleCode: type, widthMm: 600, minWidth: 300, maxWidth: 1200, shelves: type === 'rayonnage' ? 4 : 0, drawers: 0, hasRod: type === 'penderie', blockHeightsMm: [1800], blockBacks: [false], blockDoors: [true], hasBack: false, hasBackTop: false }); const configOf = (types) => ({ installationType: 'free', doorType: 'hinged', widthMm: types.length * 600, heightMm: 1900, depthMm: 600, plinthHeightMm: 100, hasBack: false, columnMode: 'separations', hardware: { drawerType: 'visible', handleBoreHeightMm: 1000 }, modules: types.map((type, index) => baseModule(index, type)), horizontalSpans: [] }); const dessusPieces = (config) => generateCuttingList(config, {}, config.modules).pieces.filter((piece) => piece.elementType === 'dessus'); const merged = dessusPieces(configOf(['penderie', 'penderie', 'penderie'])); assert.equal(merged.length, 1); assert.match(merged[0].id, /col1-3/); const split = dessusPieces(configOf(['penderie', 'rayonnage', 'penderie'])); assert.equal(split.length, 3); }); test('AXILO feet: 4 corners plus a pair every 600mm on the armoire', () => { const inset = getAxiloLateralCenterInsetMm(true); const narrow = getAxiloPlateCentersMm(500, 610, { leftEdgeCenterInsetMm: inset, rightEdgeCenterInsetMm: inset }); assert.equal(narrow.length, 4, 'A bay shorter than 600mm keeps only the 4 corner feet'); const wide = getAxiloPlateCentersMm(2362, 610, { leftEdgeCenterInsetMm: inset, rightEdgeCenterInsetMm: inset }); assert.equal(wide.length, 10, '2400mm armoire: 4 corners + 3 intermediate pairs'); const xs = [...new Set(wide.map((c) => c.xMm))].sort((a, b) => a - b); assert.equal(xs.length, 5); for (let i = 1; i < xs.length; i++) { assert.ok(xs[i] - xs[i - 1] <= PLINTH.maxFootSpacingMm + 0.01); } const left = getAxiloPlateCentersMm(761, 610, { leftEdgeCenterInsetMm: inset, rightEdgeCenterInsetMm: inset, spanWidthMm: 2362, panelOffsetMm: 0, assignMinMm: 0, assignMaxMm: 781, assignMaxInclusive: false }); const mid = getAxiloPlateCentersMm(759, 610, { leftEdgeCenterInsetMm: inset, rightEdgeCenterInsetMm: inset, spanWidthMm: 2362, panelOffsetMm: 801.5, assignMinMm: 781, assignMaxMm: 1581, assignMaxInclusive: false }); const right = getAxiloPlateCentersMm(761, 610, { leftEdgeCenterInsetMm: inset, rightEdgeCenterInsetMm: inset, spanWidthMm: 2362, panelOffsetMm: 1601.5, assignMinMm: 1581, assignMaxMm: 2362, assignMaxInclusive: true }); assert.equal(left.length + mid.length + right.length, 10, 'Split fonds share the armoire foot count'); }); test('grooved bottom staggers the rear Ixconnect row away from AXILO holes', () => { const config = makeConfig(1); const bottom = generateCuttingList(config, {}, config.modules).pieces.find( (piece) => piece.elementType === 'fond' && piece.machining?.groove && piece.machining?.axiloFootPlates && piece.machining?.carcassFasteners ); assert.ok(bottom, 'Expected a grooved bottom with AXILO and carcass machining'); const rearFootYs = bottom.machining.axiloFootPlates.centers .filter((center) => center.row === 'rear') .flatMap((center) => bottom.machining.axiloFootPlates.holeOffsetsFromCenterMm.map( (offset) => center.yMm + offset.y ) ); const rearJoint = bottom.machining.carcassFasteners.dowelStations.at(-1); const minimumAxisClearanceMm = Math.min( ...rearJoint.holesAlongMm.flatMap((jointY) => rearFootYs.map((footY) => Math.abs(jointY - footY)) ) ); assert.ok( minimumAxisClearanceMm >= 14, `Rear T–IX–T and AXILO axes need clearance; received ${minimumAxisClearanceMm} mm` ); }); test('niche front plinth spans the full wardrobe width', () => { const config = { ...makeConfig(3), installationType: 'niche', exteriorFinishesEnabled: true, sideFinishLeftEnabled: true, sideFinishRightEnabled: true }; assert.equal(getAxiloPlinthFrontSideDeductionMm(config, 'left'), 0); assert.equal(getAxiloPlinthFrontSideDeductionMm(config, 'right'), 0); const plinth = generateCuttingList(config, {}, config.modules).pieces .find((piece) => piece.elementType === 'plintheBas'); assert.ok(plinth); assert.equal(plinth.widthMm, config.widthMm); const cuttingList = generateCuttingList(config, {}, config.modules); const sideFillers = cuttingList.pieces.find((piece) => piece.elementType === 'fileurCotes'); const topFiller = cuttingList.pieces.find((piece) => piece.elementType === 'fileurHaut'); assert.ok(sideFillers); assert.equal(sideFillers.widthMm, 50); assert.equal(sideFillers.heightMm, config.heightMm + 30); assert.equal(sideFillers.quantity, 2); assert.ok(topFiller); assert.equal(topFiller.widthMm, config.widthMm + 60); assert.equal(topFiller.heightMm, 50); assert.equal(topFiller.quantity, 1); });