Files
apex/server/tests/rules.spec.mjs
lejayandCursor 30921aade6 v2.1
Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
2026-10-07 17:13:03 +02:00

1112 lines
44 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('<svg'), 'Span piece has a machining plan');
}
assert.equal(spanPieces.find((piece) => 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(/<rect\b[^>]*data-horizontal-span-door="[^"]+"[^>]*>/g) || [];
const allDoorRects = svg.match(/<rect\b[^>]*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');