v2.1
Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user