Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-10-07 17:13:03 +02:00
co-authored by Cursor
parent 6c0d7e47b8
commit 30921aade6
75 changed files with 15618 additions and 1300 deletions
+350
View File
@@ -0,0 +1,350 @@
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);
});