Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
1112 lines
44 KiB
JavaScript
1112 lines
44 KiB
JavaScript
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');
|