import assert from 'node:assert/strict'; import test from 'node:test'; import { openingVisibleBounds, openingVisibleMetrics, renderOpeningVisibleGeometry, } from '../test-build/render/opening-symbol.js'; const spec = (patch = {}) => ({ type: 'door', length: 100, angle: 0, amount: 0, flipH: false, flipV: false, base: '#000', tone: '#000', cellCm: 10, gridPitch: 20, face: { ox: 0, oy: 0, cm: 0, side: -1 }, ...patch, }); const templateText = (value) => { if (value == null || value === false) return ''; if (typeof value === 'string' || typeof value === 'number') return String(value); if (Array.isArray(value)) return value.map(templateText).join(''); if (!value.strings) return ''; return value.strings.reduce((text, part, index) => ( text + part + (index < value.values.length ? templateText(value.values[index]) : '') ), ''); }; test('opening metrics expand hit and outline zones with a thick wall face', () => { assert.deepEqual(openingVisibleMetrics(spec()), { half: 50, jambHalf: 2, gateDepth: 0, outlineHalf: 8, hitHalf: 10, }); assert.deepEqual(openingVisibleMetrics(spec({ face: { ox: 0, oy: 20, cm: 20, side: 1 } })), { half: 50, jambHalf: 20, gateDepth: 0, outlineHalf: 24, hitHalf: 25, }); }); test('equivalent grid scales preserve visual padding without double-scaling physical jambs', () => { const reference = openingVisibleMetrics(spec({ length: 100, cellCm: 5, face: { ox: 0, oy: 0, cm: 20, side: 1 }, })); const detailed = openingVisibleMetrics(spec({ length: 500, cellCm: 1, face: { ox: 0, oy: 0, cm: 20, side: 1 }, })); assert.equal(detailed.jambHalf, reference.jambHalf * 5); assert.equal(detailed.outlineHalf, reference.outlineHalf * 5); assert.equal(detailed.hitHalf, reference.hitHalf * 5); }); test('shared renderer emits the expected visible symbol for every opening type', () => { const windowText = templateText(renderOpeningVisibleGeometry(spec({ type: 'window', face: { ox: 0, oy: 20, cm: 20, side: 1 }, }))); const doorText = templateText(renderOpeningVisibleGeometry(spec({ type: 'door', amount: 1 }))); const gateText = templateText(renderOpeningVisibleGeometry(spec({ type: 'gate' }))); assert.match(windowText, /op-glass/); assert.match(doorText, /op-leaf/); assert.match(doorText, /op-arc/); assert.match(doorText, /A 100 100/); assert.match(doorText, /rotate\(-90deg\)/); assert.equal((gateText.match(/op-leaf/g) || []).length, 2); assert.ok(openingVisibleMetrics(spec({ type: 'gate' })).gateDepth > 0); }); test('an open passage emits no visible symbol at all', () => { const passageText = templateText(renderOpeningVisibleGeometry(spec({ type: 'passage', amount: 1, flipH: true, flipV: true, face: { ox: 0, oy: 20, cm: 20, side: 1 }, }))); assert.equal(passageText, ''); }); test('visible bounds cover complete state-independent opening motion and rotation', () => { const closed = openingVisibleBounds(spec({ amount: 0 }), [200, 300]); const open = openingVisibleBounds(spec({ amount: 1 }), [200, 300]); assert.deepEqual(open, closed, 'live state cannot resize a tight card'); assert.ok(closed.minX < 150 && closed.maxX > 250); assert.ok(closed.minY < 200 && closed.maxY > 300); const diagonal = openingVisibleBounds(spec({ type: 'window', angle: 45 }), [0, 0]); assert.ok(diagonal.minX < 0 && diagonal.maxX > 0); assert.ok(diagonal.minY < 0 && diagonal.maxY > 0); for (const value of Object.values(diagonal)) assert.ok(Number.isFinite(value)); }); test('visible bounds include thick jambs, both gate directions and omit passages', () => { const thin = openingVisibleBounds(spec({ type: 'gate' })); const thick = openingVisibleBounds(spec({ type: 'gate', face: { ox: 0, oy: 20, cm: 20, side: 1 }, })); const reversed = openingVisibleBounds(spec({ type: 'gate', face: { ox: 0, oy: -20, cm: 20, side: -1 }, })); assert.ok(thick.minY < thin.minY && thick.maxY > thin.maxY); assert.deepEqual( [reversed.minX, reversed.maxX, reversed.minY, reversed.maxY], [thick.minX, thick.maxX, thick.minY, thick.maxY], ); assert.equal(openingVisibleBounds(spec({ type: 'passage' })), null); }); test('shared renderer centres every flip while preserving opening direction', () => { const centred = templateText(renderOpeningVisibleGeometry(spec({ type: 'window', amount: 0.5, face: { ox: 0, oy: -20, cm: 20, side: -1 }, }))); assert.match(centred, /translate\(0 0\)/); const flippedPositive = templateText(renderOpeningVisibleGeometry(spec({ type: 'window', amount: 0.5, flipH: true, flipV: true, face: { ox: 0, oy: 20, cm: 20, side: 1 }, }))); const flippedNegative = templateText(renderOpeningVisibleGeometry(spec({ type: 'window', amount: 0.5, flipH: true, flipV: true, face: { ox: 0, oy: -20, cm: 20, side: -1 }, }))); for (const text of [flippedPositive, flippedNegative]) { assert.match(text, /scale\(-1 -1\)/); assert.match(text, /translate\(0 0\)/); assert.match(text, /rotate\(45deg\)/); assert.match(text, /stroke-dashoffset="39\.269/); assert.match(text, /op-glass/); } const doorDefault = templateText(renderOpeningVisibleGeometry(spec({ type: 'door', amount: 1, flipV: false, face: { ox: 0, oy: 20, cm: 20, side: 1 }, }))); const doorFlipped = templateText(renderOpeningVisibleGeometry(spec({ type: 'door', amount: 1, flipV: true, face: { ox: 0, oy: -20, cm: 20, side: -1 }, }))); assert.match(doorDefault, /scale\(1 1\)/); assert.match(doorFlipped, /scale\(1 -1\)/); assert.match(doorDefault, /translate\(0 0\)/); assert.match(doorFlipped, /translate\(0 0\)/); const gate = templateText(renderOpeningVisibleGeometry(spec({ type: 'gate', amount: 1, face: { ox: 0, oy: 20, cm: 20, side: 1 }, }))); const flippedGate = templateText(renderOpeningVisibleGeometry(spec({ type: 'gate', amount: 1, flipV: true, face: { ox: 0, oy: -20, cm: 20, side: -1 }, }))); assert.match(gate, /translate\(0 0\)/); assert.match(flippedGate, /translate\(0 0\)/); assert.match(gate, /scale\(1 1\)/); assert.match(flippedGate, /scale\(1 1\)/); assert.match(gate, /rotate\(10deg\)/); assert.match(gate, /rotate\(-10deg\)/); assert.match(flippedGate, /rotate\(-10deg\)/); assert.match(flippedGate, /rotate\(10deg\)/); assert.equal( gate.indexOf('rotate(10deg)') < gate.indexOf('rotate(-10deg)'), true, 'default gate turns its first leaf towards the resolved face', ); assert.equal( flippedGate.indexOf('rotate(-10deg)') < flippedGate.indexOf('rotate(10deg)'), true, 'flip_v reverses the first-leaf turn instead of cancelling in scaleY', ); });