import assert from 'node:assert/strict'; import { validateConfig } from '../services/configValidation.js'; import { generateCuttingList } from '../services/cuttingEngine.js'; import { collectHoleSpecsForPiece } from '../services/holeSpecifications.js'; import { generateAssemblyPlan } from '../services/planGenerator.js'; import { calculatePrice } from '../services/priceEngine.js'; import { generateCncProgram } from '../services/cncGenerator.js'; import { buildSingleMprText, flattenPiecesForMprExport } from '../services/mprGenerator.js'; import { computeCountsFromCuttingList } from '../services/hardwareListEngine.js'; import { getHingeCountForDoor, getSeparatorVerticalSegments, normalizeHorizontalSpan, validateHorizontalSpans } from '../../shared/manufacturing.js'; function uniqMm(values) { return [...new Set((values || []).map((v) => Math.round(Number(v) * 1000) / 1000))] .filter((v) => Number.isFinite(v)) .sort((a, b) => a - b); } function run() { const baseConfig = { installationType: 'free', widthMm: 2400, heightMm: 2400, depthMm: 610, plinthHeightMm: 100, modules: [{ type: 'tiroirs', widthMm: 900, drawers: 3, hasRod: false }], doorType: 'hinged', hardware: { handleBoreHeightMm: 1000, drawerType: 'visible' } }; const okValidation = validateConfig(baseConfig, { allow_sliding_doors: '0' }); assert.equal(okValidation.errors.length, 0, 'Base config should be valid'); const invalidDrawerConfig = { ...baseConfig, modules: [{ type: 'tiroirs', widthMm: 1201, drawers: 2, hasRod: false }] }; const invalidDrawerValidation = validateConfig(invalidDrawerConfig, {}); assert.ok( invalidDrawerValidation.errors.some((e) => String(e.message).includes('1000')), 'Drawer width limit should trigger validation error' ); const nonStandardHandle = { ...baseConfig, hardware: { ...baseConfig.hardware, handleBoreHeightMm: 1450 } }; const nonStandardHandleValidation = validateConfig(nonStandardHandle, {}); assert.ok(nonStandardHandleValidation.warnings.length > 0, 'Handle warning should be emitted'); const invalidAxiloHeight = validateConfig({ ...baseConfig, plinthHeightMm: 110 }, {}); assert.ok( invalidAxiloHeight.errors.some((e) => e.field === 'plinthHeightMm'), 'AXILO height must use one of the supplier target heights' ); const cuttingList = generateCuttingList(baseConfig, {}, baseConfig.modules); const plinthPiece = cuttingList.pieces.find((p) => p.elementType === 'plintheBas'); assert.equal(plinthPiece.heightMm, 95, 'Plinth panel height = AXILO target height − 5mm'); assert.equal(plinthPiece.thicknessMm, 19, 'AXILO plinth panel must be 19mm'); assert.equal( plinthPiece.widthMm, 2400 - 19 - 19, 'With both side claddings, the plinth sits between the floor-length habillages' ); const habillageGauche = cuttingList.pieces.find((p) => p.elementType === 'habillageFinitionGauche'); const habillageDroite = cuttingList.pieces.find((p) => p.elementType === 'habillageFinitionDroite'); assert.equal( habillageGauche.heightMm, 2400 + 19, 'Left cladding runs to the floor and miters with the top finish' ); assert.equal( habillageDroite.heightMm, 2400 + 19, 'Right cladding runs to the floor and miters with the top finish' ); const bottomPiece = cuttingList.pieces.find((p) => p.id === 'piece-fond'); const axilo = bottomPiece?.machining?.axiloFootPlates; assert.ok(axilo, 'Bottom panel should include AXILO plate machining'); assert.equal(axilo.articleNumber, '637.76.710', 'AXILO press-fit plate reference'); assert.equal(axilo.centers.length, 10, '4 corner feet + 2 feet every 600mm on a 2400mm armoire'); assert.equal(axilo.frontFootCount, 5, 'One front foot per support axis'); assert.equal(axilo.rearFootCount, 5, 'One rear foot per support axis'); const axiloXs = [...new Set(axilo.centers.map((c) => c.xMm))].sort((a, b) => a - b); for (let i = 1; i < axiloXs.length; i++) { assert.ok(axiloXs[i] - axiloXs[i - 1] <= 600 + 0.01, 'AXILO axes stay within 600mm'); } const axiloHoles = collectHoleSpecsForPiece( bottomPiece, bottomPiece.widthMm, bottomPiece.heightMm ).specs.filter((hole) => hole.type === 'axilo_press_fit'); assert.equal(axiloHoles.length, 40, 'Four Ø10 holes per AXILO plate'); assert.ok( axiloHoles.every((hole) => hole.diameterMm === 10 && hole.depthMm === 12 && hole.face === 'back'), 'AXILO holes must be Ø10×12 blind holes on the underside' ); assert.equal( axilo.rearCenterFromRearMm, 75, 'Back groove shifts rear AXILO plate centers forward by 25mm' ); const rearAxiloHoles = axiloHoles.filter((hole) => hole.row === 'rear'); assert.equal( bottomPiece.heightMm - Math.max(...rearAxiloHoles.map((hole) => hole.yMm)), 43, 'Rear AXILO holes keep 43mm from the back edge and clear the groove' ); assert.equal( Math.min(...axiloHoles.map((hole) => hole.xMm)), 15.5, 'With side cladding, the nearest AXILO hole is 15.5mm from the panel edge' ); const rawAxiloHoles = collectHoleSpecsForPiece( bottomPiece, bottomPiece.rawCutWidthMm, bottomPiece.rawCutHeightMm ).specs.filter((hole) => hole.type === 'axilo_press_fit'); assert.equal( bottomPiece.rawCutWidthMm - Math.max(...rawAxiloHoles.map((hole) => hole.xMm)), 15.5, 'Raw-workpiece AXILO holes keep the same 15.5mm inset from the right edge' ); const system32SidePiece = cuttingList.pieces.find((piece) => piece.elementType === 'cotes'); const finishedSystem32Xs = uniqMm( collectHoleSpecsForPiece( system32SidePiece, system32SidePiece.widthMm, system32SidePiece.heightMm ).specs .filter((hole) => hole.type === 'system32') .map((hole) => hole.xMm) ); const rawSystem32Xs = uniqMm( collectHoleSpecsForPiece( system32SidePiece, system32SidePiece.rawCutWidthMm, system32SidePiece.rawCutHeightMm ).specs .filter((hole) => hole.type === 'system32') .map((hole) => hole.xMm) ); assert.deepEqual(finishedSystem32Xs, [37, system32SidePiece.widthMm - 37 - 25]); assert.deepEqual(rawSystem32Xs, [37, system32SidePiece.rawCutWidthMm - 37 - 25]); const sideJointXs = uniqMm( collectHoleSpecsForPiece( system32SidePiece, system32SidePiece.widthMm, system32SidePiece.heightMm ).specs .filter((hole) => hole.face === 'end' && (hole.type === 'tourillon' || hole.type === 'ixconnect') ) .map((hole) => hole.xMm) ); assert.deepEqual( sideJointXs, [20, 50, 80, 505, 535, 565], 'Back Ixconnect group moves 25mm forward to clear the back groove' ); const noSideCladding = { ...baseConfig, exteriorFinishesEnabled: false, sideFinishLeftEnabled: false, sideFinishRightEnabled: false }; const noCladdingList = generateCuttingList( noSideCladding, {}, noSideCladding.modules ); const noCladdingBottom = noCladdingList.pieces.find((p) => p.id === 'piece-fond'); assert.equal( noCladdingList.pieces.find((p) => p.elementType === 'plintheBas').widthMm, 2400 - (20 + 19) - (20 + 19), 'Without side cladding, the front plinth sits between the two 20mm-recessed returns' ); const leftReturn = noCladdingList.pieces.find((p) => p.elementType === 'plintheGauche'); const rightReturn = noCladdingList.pieces.find((p) => p.elementType === 'plintheDroite'); assert.equal(leftReturn?.widthMm, 610 - 20, 'Left plinth return follows carcass depth minus the 20mm front setback'); assert.equal(rightReturn?.widthMm, 610 - 20, 'Right plinth return follows carcass depth minus the 20mm front setback'); assert.equal(leftReturn?.heightMm, 95); assert.equal(leftReturn?.thicknessMm, 19); assert.deepEqual( noCladdingBottom.machining.axiloFootPlates.lateralCenterInsetsMm, { left: 91.5, right: 91.5 }, 'Without side cladding, plate center = 20.5 + plinth 19 + setback 20 + 32mm hole reach' ); const noCladdingAxiloHoles = collectHoleSpecsForPiece( noCladdingBottom, noCladdingBottom.widthMm, noCladdingBottom.heightMm ).specs.filter((hole) => hole.type === 'axilo_press_fit'); assert.equal( Math.min(...noCladdingAxiloHoles.map((hole) => hole.xMm)), 59.5, 'Without side cladding, the nearest AXILO hole is 20.5 + 19 + 20 from the panel edge' ); // ── Assemblage conditionnel des côtés derrière habillage ─────────── const assemblyModule = { id: 'assembly-col', type: 'penderie', widthMm: 2400, shelves: 0, drawers: 0, hasRod: true, hasBack: true }; const assemblyConfig = { ...baseConfig, modules: [assemblyModule], exteriorFinishesEnabled: true, topFinishEnabled: true }; const cuttingForSides = (left, right, module = assemblyModule) => { const cfg = { ...assemblyConfig, modules: [module], exteriorFinishesEnabled: left || right, sideFinishLeftEnabled: left, sideFinishRightEnabled: right }; return generateCuttingList(cfg, {}, cfg.modules); }; const carcassJointHoles = (piece) => collectHoleSpecsForPiece(piece, piece.widthMm, piece.heightMm).specs .filter((hole) => hole.type === 'tourillon' || hole.type === 'ixconnect'); const noSideAssembly = cuttingForSides(false, false); assert.equal( noSideAssembly.pieces.find((piece) => piece.id === 'piece-fond').widthMm, 2362, 'Without side cladding, bottom keeps the legacy inner width' ); const legacySides = noSideAssembly.pieces.find((piece) => piece.id === 'piece-cotes'); assert.equal(legacySides.heightMm, 2300, 'Without cladding, sides keep the full carcass height'); assert.equal(legacySides.machining.connectorHoles.verticalJointMode, 'face'); assert.ok( carcassJointHoles(legacySides).every((hole) => hole.face === 'face'), 'Without cladding, structural side joints remain face-drilled' ); const leftSideAssembly = cuttingForSides(true, false); assert.equal(leftSideAssembly.pieces.find((piece) => piece.id === 'piece-fond').widthMm, 2362); assert.equal( leftSideAssembly.pieces.find((piece) => piece.elementType === 'plintheBas').widthMm, 2400 - 19 - (20 + 19), 'Front plinth sits between left habillage and the 20mm-recessed right return' ); assert.equal( leftSideAssembly.pieces.find((piece) => piece.elementType === 'plintheGauche'), undefined, 'Left cladding to the floor omits the left plinth return' ); assert.equal( leftSideAssembly.pieces.find((piece) => piece.elementType === 'plintheDroite')?.widthMm, 610 - 20, 'Unclad right side keeps a plinth return' ); assert.equal( leftSideAssembly.pieces.find((piece) => piece.elementType === 'habillageFinitionGauche').heightMm, 2400 + 19 ); assert.equal( leftSideAssembly.pieces.find((piece) => piece.elementType === 'habillageFinitionDroite'), undefined ); const leftInsetSide = leftSideAssembly.pieces.find((piece) => piece.id === 'piece-cotes-gauche'); const leftLegacySide = leftSideAssembly.pieces.find((piece) => piece.id === 'piece-cotes-droit'); assert.equal(leftInsetSide.heightMm, 2262, 'Clad left side is shortened by two panel thicknesses'); assert.equal(leftLegacySide.heightMm, 2300, 'Unclad right side keeps its legacy height'); assert.deepEqual( new Set( carcassJointHoles(leftInsetSide) .filter((hole) => hole.face === 'end') .map((hole) => `${hole.face}:${hole.end}`) ), new Set(['end:bottom', 'end:top']), 'Clad side is drilled through its bottom and top edges' ); const leftBottomHoles = carcassJointHoles( leftSideAssembly.pieces.find((piece) => piece.id === 'piece-fond') ); assert.ok(leftBottomHoles.some((hole) => hole.face === 'face')); assert.ok(leftBottomHoles.some((hole) => hole.face === 'end' && hole.end === 'right')); const rightSideAssembly = cuttingForSides(false, true); assert.equal(rightSideAssembly.pieces.find((piece) => piece.id === 'piece-dessus').widthMm, 2362); const rightInsetSide = rightSideAssembly.pieces.find((piece) => piece.id === 'piece-cotes-droit'); assert.equal( rightInsetSide.heightMm, 2262 ); const rightTopHoles = carcassJointHoles( rightSideAssembly.pieces.find((piece) => piece.id === 'piece-dessus') ); assert.ok(rightTopHoles.some((hole) => hole.face === 'end' && hole.end === 'left')); assert.ok( rightTopHoles.some((hole) => hole.face === 'back'), 'Clad right side joint on dessus is drilled from the underside (FACE=back)' ); const bothSideAssembly = cuttingForSides(true, true); assert.equal(bothSideAssembly.pieces.find((piece) => piece.id === 'piece-fond').widthMm, 2362); assert.equal( bothSideAssembly.pieces.find((piece) => piece.elementType === 'habillageFinitionDessus').widthMm, 2400, 'Top cladding stays inside the fixed 2400mm wardrobe envelope' ); const insetSidePair = bothSideAssembly.pieces.find((piece) => piece.id === 'piece-cotes'); assert.equal(insetSidePair.heightMm, 2262); assert.equal(insetSidePair.quantity, 2); assert.ok( (insetSidePair.machining?.finishHabillage?.holes || []).every( (hole) => hole.face === 'back' ), 'All side-panel habillage holes, including middle rows, enter from the outer face' ); assert.equal(bothSideAssembly.pieces.find((p) => p.elementType === 'plintheGauche'), undefined); assert.equal(bothSideAssembly.pieces.find((p) => p.elementType === 'plintheDroite'), undefined); assert.ok( carcassJointHoles(bothSideAssembly.pieces.find((piece) => piece.id === 'piece-fond')) .every((hole) => hole.face === 'face'), 'With both sides clad, both bottom joints are face-drilled' ); const bothTop = bothSideAssembly.pieces.find((piece) => piece.id === 'piece-dessus'); const bothTopHoles = carcassJointHoles(bothTop); assert.ok( bothTopHoles.filter((hole) => hole.face === 'back').length > 0, 'With both sides clad, dessus carcass joints are underside-drilled' ); const bothTopFinishXs = [ ...new Set( (bothTop?.machining?.finishHabillage?.holes || []) .filter((hole) => hole.face === 'face') .map((hole) => Math.round(hole.xMm)) ) ]; assert.deepEqual( bothTopFinishXs, [Math.round(bothTop.widthMm / 2)], 'Structural dessus has one SC habillage pair centered on the single column' ); const stackedAssemblyModule = { ...assemblyModule, blockHeightsMm: [1000, 1300], blockBacks: [true, true], blockDoors: [true, true] }; const stackedSideAssembly = cuttingForSides(true, false, stackedAssemblyModule); assert.deepEqual( stackedSideAssembly.pieces .filter((piece) => piece.id.startsWith('piece-cotes-gauche-b')) .map((piece) => piece.heightMm), [962, 1262], 'Every stacked block side behind the cladding is shortened independently' ); assert.ok( stackedSideAssembly.pieces .filter((piece) => piece.id.includes('-jonction')) .every((piece) => piece.widthMm === 2362), 'Stacked top/bottom joint panels keep the fixed wardrobe envelope width' ); const stackedHabillage = stackedSideAssembly.pieces.find( (piece) => piece.elementType === 'habillageFinitionGauche' ); const stackedHabillageYs = uniqMm( (stackedHabillage?.machining?.finishHabillage?.holes || []) .filter((hole) => hole.face === 'face') .map((hole) => hole.yMm) ); assert.ok( stackedHabillageYs.some((y) => Math.abs(y - 600) < 1), 'Left habillage has a hole row at the center of the bottom block (plinth 100 + 1000/2)' ); assert.ok( stackedHabillageYs.some((y) => Math.abs(y - 1750) < 1), 'Left habillage has a hole row at the center of the top block (plinth 100 + 1000 + 1300/2)' ); assert.ok( stackedHabillageYs.every((y) => Math.abs(y - (2419 / 2)) > 80), 'Left habillage holes are not centered on the full wardrobe/panel height' ); const twoColumnHabillageCfg = { ...assemblyConfig, modules: [ { ...assemblyModule, id: 'col-a', widthMm: 900 }, { ...assemblyModule, id: 'col-b', widthMm: 1500 } ] }; const twoColumnList = generateCuttingList(twoColumnHabillageCfg, {}, twoColumnHabillageCfg.modules); const topHabillage = twoColumnList.pieces.find((piece) => piece.elementType === 'habillageFinitionDessus'); const topHabillageXs = uniqMm( (topHabillage?.machining?.finishHabillage?.holes || []) .filter((hole) => hole.face === 'face') .map((hole) => hole.xMm) ); assert.deepEqual( topHabillageXs, [450, 1650], 'Top habillage Ixconnect holes are centered on each column, not on the wardrobe width' ); const mixedOuterPricingData = { settings: [{ key: 'allow_sliding_doors', value: '0' }], modules: [], finishes: [], serviceOptions: [], handles: [], hinges: [], blumHardware: [] }; const mixedOuterBase = { installationType: 'free', widthMm: 2400, heightMm: 2400, depthMm: 610, plinthHeightMm: 100, hasBack: true, exteriorFinishesEnabled: true, topFinishEnabled: true }; const wholeLeft = { type: 'penderie', widthMm: 1200, hasBack: true, blockHeightsMm: [2300], blockBacks: [true] }; const stackedRight = { type: 'penderie', widthMm: 1200, hasBack: true, blockHeightsMm: [1000, 1300], blockBacks: [true, true] }; const dropLeftHabillage = calculatePrice( { ...mixedOuterBase, sideFinishLeftEnabled: false, sideFinishRightEnabled: true, modules: [wholeLeft, stackedRight] }, mixedOuterPricingData ); assert.equal( dropLeftHabillage.errors, undefined, 'A whole-block left column can drop left habillage even if the right column is stacked' ); const dropRightHabillage = calculatePrice( { ...mixedOuterBase, sideFinishLeftEnabled: true, sideFinishRightEnabled: false, modules: [stackedRight, wholeLeft] }, mixedOuterPricingData ); assert.equal( dropRightHabillage.errors, undefined, 'A whole-block right column can drop right habillage even if the left column is stacked' ); const stackedLeftMissingHabillage = calculatePrice( { ...mixedOuterBase, sideFinishLeftEnabled: false, sideFinishRightEnabled: true, modules: [stackedRight, wholeLeft] }, mixedOuterPricingData ); assert.ok( stackedLeftMissingHabillage.errors?.some((error) => error.field === 'sideFinishLeftEnabled'), 'A stacked left column still requires left habillage' ); const sidePiece = cuttingList.pieces.find((p) => p.elementType === 'cotes'); assert.ok(sidePiece?.machining?.groove, 'Side piece should include groove machining'); assert.equal(sidePiece.machining.groove.width, 9, 'Groove width should be 9mm'); assert.equal(sidePiece.machining.groove.depth, 10.5, 'Groove depth should be 10.5mm'); assert.equal(sidePiece.machining.groove.axisFromPanelEdge, 16, 'Groove position should be 16mm from edge'); const sideMpr = buildSingleMprText({ piece: sidePiece, slug: 'SE_L', qtyIndex: 0, seqGlobal: 1, groupIndex: 1, orderReference: 'orientation-regression', stackListKm: 'orientation-regression', defaultMaterialArticle: 'APEX' }); assert.ok( sideMpr.includes('DRWIN="270.0"'), 'A vertical MPR blank is rotated lengthwise to match the machine orientation' ); const topPiece = cuttingList.pieces.find((p) => p.elementType === 'dessus'); const topMpr = buildSingleMprText({ piece: topPiece, slug: 'BO_U', qtyIndex: 0, seqGlobal: 1, groupIndex: 1, orderReference: 'groove-regression', stackListKm: 'groove-regression', defaultMaterialArticle: 'APEX' }); assert.ok( topMpr.includes('DRWIN="0.0"'), 'A horizontal MPR blank keeps the native machine orientation' ); assert.ok(topMpr.includes('<109 \\Nuten\\'), 'Back groove uses the native WoodWOP straight-groove operation'); assert.ok(!topMpr.includes('RSO_Nut_Flaeche.mpr'), 'Back groove does not depend on an external RSO macro'); assert.ok( topMpr.includes(`YA="${topPiece.rawCutHeightMm - 16}.0"`) && topMpr.includes(`YE="${topPiece.rawCutHeightMm - 16}.0"`), 'Top-panel back groove stays parallel to X at 16mm from the rear edge' ); const bottomPanel = cuttingList.pieces.find((p) => p.elementType === 'fond'); const bottomPanelWidth = bottomPanel.rawCutWidthMm ?? bottomPanel.widthMm; const bottomPanelHeight = bottomPanel.rawCutHeightMm ?? bottomPanel.heightMm; const bottomPanelSpecs = collectHoleSpecsForPiece( bottomPanel, bottomPanelWidth, bottomPanelHeight ).specs; const bottomPanelMpr = buildSingleMprText({ piece: bottomPanel, slug: 'BO_L', qtyIndex: 0, seqGlobal: 1, groupIndex: 1, orderReference: 'underside-regression', stackListKm: 'underside-regression', defaultMaterialArticle: 'APEX' }); const expectedUnderDrillings = bottomPanelSpecs.filter((spec) => spec.face === 'back').length; assert.ok(expectedUnderDrillings > 0, 'Bottom panel should include AXILO underside holes'); assert.equal( (bottomPanelMpr.match(/BM="LSU"/g) || []).length, expectedUnderDrillings, 'AXILO holes use the native WoodWOP drilling-from-under cycle' ); assert.ok(!bottomPanelMpr.includes('<117 \\NCStop\\'), 'Underside drilling does not request a panel flip'); assert.ok(bottomPanelMpr.includes('VA="NCSTOPP 0"'), 'The label macro does not request a panel flip'); // ── Blum Legrabox pure ────────────────────────────────────────────── // Config: depth 610 → prof. utile 600, tiroirs visibles, hauteurs par défaut [90,128,177,240]. // Module 900 mm → X = 900 − 2×19 = 862 mm. NL commun (40 kg) = 550 (systèmes M/K/C, min. K ≤ 597−3). // 1. Débit — fond de tiroir 16 mm : L = NL − 10, l = X − 35 const bottom = cuttingList.pieces.find((p) => p.elementType === 'tiroirFond'); assert.ok(bottom, 'Cutting list should include Legrabox drawer bottoms'); assert.equal(bottom.thicknessMm, 16, 'Drawer bottom must be 16mm'); assert.equal(bottom.widthMm, 550 - 10, 'Bottom length = NL − 10'); assert.equal(bottom.heightMm, 862 - 35, 'Bottom width = X − 35'); assert.equal(bottom.quantity, 3, 'One bottom per drawer'); assert.ok(bottom.machining?.legraboxBottom?.rebate, 'Bottom should carry 38×8 rebate machining'); // Débit — parois arrière 16 mm : l = X − 38, h selon système (M 63 / K 101 / C 148) const backs = cuttingList.pieces.filter((p) => p.elementType === 'tiroirDos'); assert.ok(backs.length >= 3, 'One back panel line per drawer height system'); const backHeights = backs.map((p) => p.heightMm).sort((a, b) => a - b); assert.deepEqual(backHeights, [63, 101, 148], 'Back heights follow M/K/C systems'); for (const b of backs) { assert.equal(b.widthMm, 862 - 38, 'Back width = X − 38'); assert.equal(b.thicknessMm, 16, 'Back must be 16mm'); } // 2. Perçage caisson — positions cumulées depuis le chant avant (40 kg), bornées à NL 550 const slide = sidePiece.machining.drawerSlideDrilling; assert.ok(slide?.cotes?.leftInnerFace, 'Side panel should carry Legrabox rail drilling'); assert.deepEqual( slide.positionsXFromFrontMm, [37, 69, 197, 229, 261, 293, 357], 'Rail positions per 40 kg table (all ≤ NL 550)' ); assert.equal(slide.cotes.leftInnerFace.railAxesYMm.length, 3, 'One screw line per drawer'); // 3. Façades — INSERTA Ø10 (2 par côté + rangées B pour C/F) const facades = cuttingList.pieces.filter((p) => p.elementType === 'tiroirFacade'); assert.ok(facades.length >= 1, 'Cutting list should include drawer facades'); const facadeC = facades.find((p) => p.machining?.legraboxInserta?.systemKey === 'C'); assert.ok(facadeC, 'System C facade should exist (177 side height)'); assert.equal(facadeC.heightMm, 193 - 8, 'Facade height = niche SMS − 8mm gap'); assert.equal(facadeC.machining.legraboxInserta.holes.length, 8, 'System C: (2 + 2 coteB) × 2 sides'); assert.equal(facadeC.machining.legraboxInserta.holeDiameterMm, 10, 'INSERTA Ø10'); // ── Multi-column horizontal spans ───────────────────────────────── const openModule = (id, widthMm = 800) => ({ id, type: 'penderie', moduleCode: id, widthMm, shelves: 0, drawers: 0, hasRod: false, hasBack: true, hasBackTop: true, blockHeightsMm: [700, 800, 800], blockBacks: [true, true, true], blockDoors: [false, false, false], stacked: true, stackBottomHeightMm: 700 }); const spanBase = { ...baseConfig, widthMm: 2400, modules: [openModule('col-a'), openModule('col-b'), openModule('col-c')], exteriorFinishesEnabled: true, sideFinishLeftEnabled: true, sideFinishRightEnabled: true, topFinishEnabled: true, hardware: { ...baseConfig.hardware, drawerType: 'visible' } }; const twoColumnSpan = { id: 'span-two', moduleIds: ['col-a', 'col-b'], bottomMm: 700, topMm: 1500, hasBack: true, type: 'open' }; const threeColumnSpan = { id: 'span-three', moduleIds: ['col-a', 'col-b', 'col-c'], bottomMm: 1500, topMm: 2300, hasBack: false, type: 'open' }; const validTwo = { ...spanBase, horizontalSpans: [twoColumnSpan] }; const validThree = { ...spanBase, horizontalSpans: [threeColumnSpan] }; assert.deepEqual(validateHorizontalSpans(validTwo), [], 'Shared validator accepts a valid two-column span'); assert.deepEqual(validateHorizontalSpans(validThree), [], 'Shared validator accepts a valid three-column span'); assert.equal(validateConfig(validTwo, {}).errors.length, 0, 'Config validation accepts a valid horizontal span'); assert.equal(validateConfig(validThree, {}).errors.length, 0, 'Config validation accepts a valid three-column span'); const issueCodes = (horizontalSpans, modules = spanBase.modules) => validateHorizontalSpans({ ...spanBase, modules, horizontalSpans }).map((issue) => issue.code); assert.ok( issueCodes([{ ...twoColumnSpan, id: 'gap', moduleIds: ['col-a', 'col-c'] }]).includes('noncontiguous_modules'), 'Noncontiguous columns are rejected' ); assert.ok( issueCodes([{ ...twoColumnSpan, id: 'misaligned', topMm: 1400 }]).includes('misaligned_boundaries'), 'Mismatched vertical block boundaries are rejected' ); assert.ok( issueCodes([ twoColumnSpan, { ...twoColumnSpan, id: 'overlap', moduleIds: ['col-b', 'col-c'], bottomMm: 700, topMm: 1500 } ]).includes('overlap'), 'Overlapping spans sharing a column are rejected' ); const doorModules = spanBase.modules.map((module, index) => index === 1 ? { ...module, blockDoors: [false, true, false] } : module ); assert.ok( issueCodes([twoColumnSpan], doorModules).includes('incompatible_door'), 'A door in a covered band is rejected' ); const drawerModules = spanBase.modules.map((module, index) => index === 0 ? { ...module, drawers: 1 } : module ); const bottomSpan = { ...twoColumnSpan, id: 'bottom', bottomMm: 0, topMm: 700 }; assert.ok( issueCodes([bottomSpan], drawerModules).includes('incompatible_drawers'), 'Drawers in a covered bottom band are rejected' ); assert.deepEqual( getSeparatorVerticalSegments(validTwo, 0), [{ bottomMm: 0, topMm: 700 }, { bottomMm: 1500, topMm: 2300 }], 'Covered separator is split above and below the horizontal opening' ); assert.deepEqual( getSeparatorVerticalSegments(validTwo, 1), [{ bottomMm: 0, topMm: 2300 }], 'Uncovered separator remains continuous' ); const spanCutting = generateCuttingList(validTwo, {}, validTwo.modules); const spanPieces = spanCutting.pieces.filter((piece) => piece.horizontalSpanId === twoColumnSpan.id); const outerTopPanels = spanCutting.pieces.filter((piece) => /^piece-dessus-col\d+$/.test(piece.id)); const outerBottomPanels = spanCutting.pieces.filter((piece) => /^piece-fond-col\d+$/.test(piece.id)); assert.deepEqual( outerTopPanels.map((piece) => piece.widthMm), [761, 759, 761], 'Roof boards stop at the inner face of each montant (19 mm exterior, 20.5 mm at a double-montant joint)' ); assert.deepEqual( outerBottomPanels.map((piece) => piece.widthMm), [761, 759, 761], 'Base boards stop at the inner face of each montant (19 mm exterior, 20.5 mm at a double-montant joint)' ); assert.equal( spanCutting.pieces.some((piece) => piece.id === 'piece-dessus' || piece.id === 'piece-fond'), false, 'A multi-block wardrobe no longer emits full-width structural boards' ); assert.deepEqual( spanPieces.map((piece) => piece.horizontalSpanRole).sort(), ['back', 'bottom', 'top'], 'A backed span emits one continuous bottom, top, and back' ); for (const piece of spanPieces) { assert.equal( piece.widthMm, 1561, 'Horizontal span stops at the inner face of the clad outer side and the double-montant joint' ); assert.equal(piece.horizontalSpanId, 'span-two', 'Span metadata keeps the stable id'); assert.deepEqual(piece.moduleIds, ['col-a', 'col-b'], 'Span metadata keeps ordered module ids'); assert.equal(piece.bottomMm, 700); assert.equal(piece.topMm, 1500); assert.ok(piece.piecePlanSvg.includes(' piece.horizontalSpanRole === 'bottom').heightMm, 610); assert.equal(spanPieces.find((piece) => piece.horizontalSpanRole === 'top').heightMm, 610); assert.equal(spanPieces.find((piece) => piece.horizontalSpanRole === 'back').heightMm, 762); const threeColumnCutting = generateCuttingList(validThree, {}, validThree.modules); const threeColumnPieces = threeColumnCutting.pieces.filter( (piece) => piece.horizontalSpanId === threeColumnSpan.id ); const threeColumnRoofs = threeColumnCutting.pieces.filter((piece) => /^piece-dessus-col\d+$/.test(piece.id)); assert.deepEqual( threeColumnPieces.map((piece) => [piece.horizontalSpanRole, piece.widthMm]).sort(), [['bottom', 2362]], 'A span that reaches the roof reuses the carcass top and only cuts its own floor' ); assert.equal(threeColumnRoofs.length, 3, 'Carcass roof boards stay in the cutting list under a top span'); const validBottom = { ...spanBase, horizontalSpans: [bottomSpan] }; const bottomSpanCutting = generateCuttingList(validBottom, {}, validBottom.modules); const bottomSpanPieces = bottomSpanCutting.pieces.filter( (piece) => piece.horizontalSpanId === bottomSpan.id ); const bottomSpanFloors = bottomSpanCutting.pieces.filter((piece) => /^piece-fond-col\d+$/.test(piece.id)); assert.deepEqual( bottomSpanPieces.map((piece) => piece.horizontalSpanRole).sort(), ['back', 'top'], 'A span on the floor reuses the carcass bottom and only cuts its own ceiling' ); assert.equal(bottomSpanFloors.length, 3, 'Carcass floor boards stay in the cutting list under a bottom span'); const bottomSpanFootCount = bottomSpanFloors.reduce( (n, piece) => n + (piece.machining?.axiloFootPlates?.centers?.length || 0), 0 ); assert.equal( bottomSpanFootCount, 10, 'AXILO feet are counted on the armoire (4 + 2 every 600mm), not 4 per bottom board' ); assert.ok( bottomSpanFloors.every((piece) => piece.machining?.axiloFootPlates?.centers?.length > 0), 'AXILO plates stay on the carcass floor, not on a duplicate span bottom' ); assert.ok( bottomSpanFloors.some((piece) => /Panneau du fond \(bas\)/.test(piece.description)), 'Cutting list still names the structural floor as panneau du fond (bas)' ); const splitSeparatorPieces = spanCutting.pieces.filter( (piece) => piece.elementType === 'panneauxInterieurs' && piece.separatorIndex === 0 ); assert.deepEqual( splitSeparatorPieces.map((piece) => [piece.bottomMm, piece.topMm, piece.heightMm]), [[0, 700, 662], [1500, 2300, 762]], 'Cutting list emits the two physical separator remnants with stable extents' ); assert.equal( spanCutting.pieces.filter((piece) => piece.id === 'piece-panneauxInterieurs').length, 0, 'The replaced full-height separator line is not duplicated' ); assert.equal( spanCutting.pieces.filter((piece) => piece.id.startsWith('piece-dessus-jonction')).reduce((n, p) => n + p.quantity, 0), 4, 'Only non-replaced per-column top joint panels remain' ); assert.equal( spanCutting.pieces.filter((piece) => piece.id.startsWith('piece-fond-jonction')).reduce((n, p) => n + p.quantity, 0), 4, 'Only non-replaced per-column bottom joint panels remain' ); for (const piece of [...spanPieces, ...splitSeparatorPieces]) { const { specs } = collectHoleSpecsForPiece(piece, piece.rawCutWidthMm, piece.rawCutHeightMm); assert.ok( specs.every((hole) => Number.isFinite(hole.xMm) && Number.isFinite(hole.yMm) && hole.xMm >= 0 && hole.xMm <= piece.rawCutWidthMm && hole.yMm >= 0 && hole.yMm <= piece.rawCutHeightMm ), `Machining holes remain inside ${piece.id}` ); } const cnc = generateCncProgram(validTwo, validTwo.modules, {}); assert.ok(cnc.content.includes('PANNEAU_HORIZONTAL_BAS'), 'CNC output includes span pieces'); assert.ok(!cnc.content.includes('NaN') && !cnc.content.includes('Infinity'), 'CNC output contains finite coordinates'); const flattenedSpan = flattenPiecesForMprExport(spanPieces); assert.equal(flattenedSpan.length, 3, 'MPR export emits one file per continuous span piece'); for (const [index, entry] of flattenedSpan.entries()) { const mpr = buildSingleMprText({ ...entry, seqGlobal: index + 1, groupIndex: 1, orderReference: 'span-regression', stackListKm: 'span-regression', defaultMaterialArticle: 'APEX' }); assert.ok(mpr.includes('[H') && !mpr.includes('NaN') && !mpr.includes('Infinity'), 'MPR span output is finite'); } const pricingData = { settings: [{ key: 'allow_sliding_doors', value: '0' }], modules: [], finishes: [], serviceOptions: [], handles: [], hinges: [], blumHardware: [] }; const spanPrice = calculatePrice(validTwo, pricingData); assert.equal(spanPrice.errors, undefined, 'Valid horizontal span prices without validation errors'); assert.ok(Number.isFinite(spanPrice.totalChf), 'Horizontal span total price is finite'); const assemblySvg = generateAssemblyPlan(validTwo, validTwo.modules); assert.equal((assemblySvg.match(/class="horizontal-span"/g) || []).length, 2, 'Assembly plan draws both span limits'); assert.ok(assemblySvg.includes('Portée horizontale — colonnes 1–2'), 'Assembly plan labels covered columns'); // ── Horizontal-span door modes ──────────────────────────────────── const unequalModules = [ openModule('door-col-a', 700), openModule('door-col-b', 900), openModule('door-col-c', 800) ].map((module) => ({ ...module, blockDoors: [true, false, true] })); const doorSpanBase = { ...spanBase, modules: unequalModules, hardware: { ...spanBase.hardware, handleVariantId: 'handle-test', hingeVariantId: 'hinge-test' } }; const legacyDoorSpan = { id: 'span-door-modes', moduleIds: ['door-col-b', 'door-col-a'], bottomMm: 700, topMm: 1500, hasBack: true, type: 'open' }; const normalizedLegacyDoorSpan = normalizeHorizontalSpan(legacyDoorSpan, unequalModules); assert.equal(normalizedLegacyDoorSpan.doorMode, 'none', 'Missing doorMode normalizes to none'); assert.deepEqual( normalizedLegacyDoorSpan.moduleIds, ['door-col-a', 'door-col-b'], 'Normalization preserves canonical module order' ); assert.equal( validateHorizontalSpans({ ...doorSpanBase, horizontalSpans: [legacyDoorSpan] }).length, 0, 'Missing doorMode remains backward compatible' ); assert.ok( validateHorizontalSpans({ ...doorSpanBase, horizontalSpans: [{ ...legacyDoorSpan, doorMode: 'double' }] }).some((issue) => issue.code === 'bad_door_mode'), 'Unknown horizontal-span door modes are rejected' ); assert.equal( normalizeHorizontalSpan({ ...legacyDoorSpan, doorMode: 'double' }, unequalModules).doorMode, 'none', 'Normalization safely coerces an unknown door mode to none' ); const doorModeConfig = (doorMode) => ({ ...doorSpanBase, horizontalSpans: [{ ...legacyDoorSpan, moduleIds: ['door-col-a', 'door-col-b', 'door-col-c'], doorMode }] }); const cuttingByDoorMode = Object.fromEntries( ['none', 'perColumn', 'single'].map((doorMode) => [ doorMode, generateCuttingList(doorModeConfig(doorMode), {}, unequalModules) ]) ); const spanDoors = (doorMode) => cuttingByDoorMode[doorMode].pieces.filter( (piece) => piece.horizontalSpanId === legacyDoorSpan.id && piece.horizontalSpanRole === 'door' ); assert.equal(spanDoors('none').length, 0, 'none emits no horizontal-span door pieces'); const perColumnDoors = spanDoors('perColumn'); assert.equal(perColumnDoors.length, 2, 'perColumn emits exactly two doors across a three-column span'); assert.deepEqual( perColumnDoors.map((piece) => piece.widthMm), [1196, 1196], 'perColumn emits two equal span-half doors with a 2mm reveal on both sides' ); assert.deepEqual( perColumnDoors.map((piece) => piece.doorModuleId), [null, null], 'perColumn doors are span leaves rather than module-owned doors' ); assert.deepEqual( perColumnDoors.map((piece) => [piece.doorMode, piece.doorIndex, piece.doorNumber, piece.doorCount]), [['perColumn', 0, 1, 2], ['perColumn', 1, 2, 2]], 'perColumn door metadata has stable indexes and totals' ); assert.deepEqual( perColumnDoors.map((piece) => [ piece.hingeSide, piece.doorPlanHints?.hingeSide, piece.doorPlanHints?.hingeSides ]), [['left', 'left', ['left']], ['right', 'right', ['right']]], 'perColumn cutting and MPR metadata force hinges to the two outer span sides' ); const singleDoors = spanDoors('single'); assert.equal(singleDoors.length, 1, 'single emits exactly one horizontal-span door'); assert.equal(singleDoors[0].widthMm, 2396, 'single spans 2400mm with a 2mm reveal on both sides'); assert.equal(singleDoors[0].heightMm, 796, 'Span doors keep a 2mm top and bottom reveal'); assert.equal(singleDoors[0].doorMode, 'single'); assert.equal(singleDoors[0].doorCount, 1); assert.equal(singleDoors[0].doorModuleId, null, 'A single spanning door is not assigned to one module'); for (const door of [...perColumnDoors, ...singleDoors]) { assert.ok( [door.widthMm, door.heightMm, door.rawCutWidthMm, door.rawCutHeightMm] .every(Number.isFinite), `${door.id} dimensions remain finite` ); const { specs } = collectHoleSpecsForPiece(door, door.rawCutWidthMm, door.rawCutHeightMm); assert.ok(specs.length > 0, `${door.id} emits machining hole specifications`); assert.ok( specs.every((hole) => [hole.xMm, hole.yMm, hole.diameterMm, hole.depthMm].every(Number.isFinite) && hole.xMm >= 0 && hole.xMm <= door.rawCutWidthMm && hole.yMm >= 0 && hole.yMm <= door.rawCutHeightMm ), `${door.id} machining holes stay finite and inside the raw panel` ); assert.ok( !door.piecePlanSvg.includes('NaN') && !door.piecePlanSvg.includes('Infinity'), `${door.id} piece plan is finite` ); } const doorMprEntries = flattenPiecesForMprExport([...perColumnDoors, ...singleDoors]); assert.equal(doorMprEntries.length, 3, 'MPR flattening keeps every span door distinct'); assert.deepEqual( doorMprEntries.slice(0, 2).map((entry) => entry.piece.doorPlanHints.hingeSide), ['left', 'right'], 'MPR entries preserve the forced outer hinge sides' ); doorMprEntries.forEach((entry, index) => { const mpr = buildSingleMprText({ ...entry, seqGlobal: index + 1, groupIndex: 1, orderReference: 'span-door-regression', stackListKm: 'span-door-regression', defaultMaterialArticle: 'APEX' }); assert.ok( mpr.includes('[H') && !mpr.includes('NaN') && !mpr.includes('Infinity'), `MPR output is finite for ${entry.piece.id}` ); }); const expectedRegularDoorCount = 6; const expectedSpanDoorCounts = { none: 0, perColumn: 2, single: 1 }; const expectedHingesPerDoor = getHingeCountForDoor({ heightMm: 796 }); for (const doorMode of ['none', 'perColumn', 'single']) { const counts = computeCountsFromCuttingList(cuttingByDoorMode[doorMode]); assert.equal( counts.doorCount, expectedRegularDoorCount + expectedSpanDoorCounts[doorMode], `Hardware list counts regular and ${doorMode} span doors once` ); assert.equal( counts.items.find((item) => item.key === 'hinge')?.quantity, counts.doorCount * expectedHingesPerDoor, `Hardware list has the correct hinge quantity for ${doorMode}` ); } assert.equal( computeCountsFromCuttingList(cuttingByDoorMode.perColumn).doorCount - computeCountsFromCuttingList(cuttingByDoorMode.none).doorCount, 2, 'A three-column perColumn span adds exactly two doors to hardware counts' ); const doorPricingData = { ...pricingData, handles: [{ id: 'handle-test', name: 'Test handle', price_chf: 10 }], hinges: [{ id: 'hinge-test', name: 'Test hinge', price_chf: 2 }], finishes: [{ id: 'finish-test', code: 'finish-test', price_chf: 100, width_mm: 2800, height_mm: 2070 }] }; const pricesByDoorMode = Object.fromEntries( ['none', 'perColumn', 'single'].map((doorMode) => { const price = calculatePrice( { ...doorModeConfig(doorMode), finishId: 'finish-test' }, doorPricingData ); assert.equal(price.errors, undefined, `${doorMode} span-door pricing validates`); assert.ok( [price.totalChf, price.surfaceM2, price.details.panelsChf, price.details.hardwareChf] .every(Number.isFinite), `${doorMode} pricing remains finite` ); const handleLine = price.details.hardwareBreakdown.find((line) => line.type === 'handle'); const hingeLine = price.details.hardwareBreakdown.find((line) => line.type === 'hinge'); const expectedDoors = expectedRegularDoorCount + expectedSpanDoorCounts[doorMode]; assert.equal(handleLine.quantity, expectedDoors, `${doorMode} pricing has one handle per door`); assert.equal( hingeLine.quantity, expectedDoors * expectedHingesPerDoor, `${doorMode} pricing has matrix-derived hinges per door` ); return [doorMode, price]; }) ); assert.ok( Math.abs( (pricesByDoorMode.perColumn.surfaceM2 - pricesByDoorMode.none.surfaceM2) - 3.808 ) < 1e-9, 'perColumn pricing adds both equal three-column span leaves once' ); assert.ok( Math.abs( (pricesByDoorMode.single.surfaceM2 - pricesByDoorMode.none.surfaceM2) - 3.814 ) < 1e-9, 'single pricing adds the full-span door surface once' ); for (const doorMode of ['none', 'perColumn', 'single']) { const svg = generateAssemblyPlan(doorModeConfig(doorMode), unequalModules); const spanDoorRects = svg.match(/]*data-horizontal-span-door="[^"]+"[^>]*>/g) || []; const allDoorRects = svg.match(/]*class="door"[^>]*>/g) || []; assert.equal( spanDoorRects.length, expectedSpanDoorCounts[doorMode], `Assembly SVG draws the correct ${doorMode} span-door rectangles` ); assert.ok( spanDoorRects.every((rect) => rect.includes(`data-horizontal-span-door="${doorMode}"`)), `Assembly SVG labels ${doorMode} rectangles by mode` ); if (doorMode === 'perColumn') { assert.deepEqual( spanDoorRects.map((rect) => rect.match(/data-hinge-side="([^"]+)"/)?.[1]), ['left', 'right'], 'Assembly SVG forces hinges to the outer sides of the two span halves' ); } assert.equal( allDoorRects.length, expectedRegularDoorCount + expectedSpanDoorCounts[doorMode], `Assembly SVG does not draw a full-height overlay through the span band for ${doorMode}` ); } const legacyWithoutSpans = generateCuttingList(spanBase, {}, spanBase.modules); const legacyWithEmptySpans = generateCuttingList({ ...spanBase, horizontalSpans: [] }, {}, spanBase.modules); assert.deepEqual( legacyWithEmptySpans, legacyWithoutSpans, 'Legacy cutting output is unchanged when horizontalSpans is absent versus empty' ); } run(); console.log('rules.spec.mjs: all assertions passed');