/** * Side-wall paintings must not hang in front of bay columns/pilasters. * Columns stay fixed; the hang skips column keep-outs (and windows must too). * node --test server/__tests__/painting-column-clearance.test.js */ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const ROOT = path.join(__dirname, '..', '..'); const KEEP = fs.readFileSync(path.join(ROOT, 'client/src/utils/sideColumnKeepOuts.ts'), 'utf8'); const LAYOUT = fs.readFileSync(path.join(ROOT, 'client/src/utils/movementHallLayout.ts'), 'utf8'); const GEOMETRY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/geometry.ts'), 'utf8'); const GALLERY = fs.readFileSync(path.join(ROOT, 'client/src/components/VirtualGallery.tsx'), 'utf8'); /** Mirror of bayPositions — must stay in lockstep with sideColumnKeepOuts.ts / geometry.ts */ function bayPositions(depth, halfD, spacing = 4.2, max = 9) { const count = Math.max(2, Math.min(max, Math.floor(depth / spacing))); const step = depth / (count + 1); return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1)); } function parseRhythmTable(src) { const start = src.indexOf('SIDE_WALL_COLUMN_RHYTHMS'); assert.ok(start >= 0, 'SIDE_WALL_COLUMN_RHYTHMS export missing'); const bodyStart = src.indexOf('{', start); const bodyEnd = src.indexOf('} as const', bodyStart); assert.ok(bodyEnd > bodyStart); const body = src.slice(bodyStart, bodyEnd + 1); const rhythms = {}; for (const m of body.matchAll( /['"]?([a-z0-9-]+)['"]?:\s*\{\s*spacing:\s*([\d.]+),\s*max:\s*(\d+),\s*halfClear:\s*([\d.]+)\s*\}/g )) { rhythms[m[1]] = { spacing: Number(m[2]), max: Number(m[3]), halfClear: Number(m[4]) }; } return rhythms; } describe('Painting ↔ column clearance', () => { it('exports bayPositions shared by geometry and keep-outs', () => { assert.match(KEEP, /export function bayPositions/); assert.match(GEOMETRY, /bayPositions/); assert.match(GEOMETRY, /useBayZ[\s\S]*bayPositions/); }); it('registers keep-outs for every mid-wall bay column style', () => { const rhythms = parseRhythmTable(KEEP); for (const kind of [ 'gothic', 'roman', 'quattrocento', 'cinquecento', 'mannerist', 'neoclassical', 'gothic-revival', 'art-nouveau', ]) { assert.ok(rhythms[kind], `missing rhythm for ${kind}`); assert.ok(rhythms[kind].halfClear >= 0.18, `${kind} halfClear too small`); } }); it('layout packs side walls around column keep-outs and grows depth if needed', () => { assert.match(LAYOUT, /sideColumnKeepOutIntervals/); assert.match(LAYOUT, /depthForSideHang|sideWallGaps/); assert.match(LAYOUT, /details\?:\s*MovementDetailKind/); assert.match(LAYOUT, /buildAllMovementHallLayouts\(\s*paintings:\s*Painting\[\],\s*details\?/); }); it('windows treat column keep-outs as occupied so they do not land on columns', () => { const fn = LAYOUT.slice(LAYOUT.indexOf('export function computeSideWallWindows')); assert.match(fn, /sideColumnKeepOutIntervals/); assert.match(fn, /columnKeepOuts/); }); it('VirtualGallery passes the movement detail kind into hall layout', () => { assert.match(GALLERY, /buildAllMovementHallLayouts\(\s*paintings\s*,\s*interiorStyle\?\.details\s*\)/); }); it('bay keep-out intervals cover each column centre for a typical hall depth', () => { const rhythms = parseRhythmTable(KEEP); const depth = 14; const halfD = depth / 2; for (const [kind, r] of Object.entries(rhythms)) { const zs = bayPositions(depth, halfD, r.spacing, r.max); assert.ok(zs.length >= 2, kind); for (const z of zs) { const lo = z - r.halfClear; const hi = z + r.halfClear; assert.ok(lo < z && z < hi, `${kind} keep-out must contain bay centre`); // A typical frame half-width centred on the column would collide const halfOuter = 0.35; assert.ok(z - halfOuter < hi && z + halfOuter > lo, `${kind} frame would overlap column`); } } }); });