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