import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { clampToEdgeEnds, snapOpenPoint, resolveOpenCuts, hitSharedWall, hitOuterWall, clearThicknessUnderSpan, thicknessOnClose, purgeOpeningsOnSpan, pointOnOpenCut, spanToEntry, entryToSeg, syncOpenToFromCuts, removeCut, hitOpenSpan, clipOpenSpansToShared, sanitizeOpenSpans, degradeOpenSpans, rekeyOpenSpansAfterMove, resolveBoundaryTarget, } from '../test-build/open-spans.js'; import { wallKey, setWallThickness, DRAW_WALL_DEFAULT_CM } from '../test-build/wall-thickness.js'; const pitch = 0.01; const scale = 1000; const eps = 0.02; describe('open-spans', () => { it('clampToEdgeEnds keeps point on the edge', () => { const edge = [0, 0, 10, 0]; const q = clampToEdgeEnds([5, 3], edge); assert.equal(q[0], 5); assert.equal(q[1], 0); const past = clampToEdgeEnds([20, 1], edge); assert.equal(past[0], 10); }); it('snapOpenPoint prefers a joint over the grid', () => { const edge = [0, 0, 10, 0]; const joints = [[0, 0], [10, 0], [4, 0]]; const p = snapOpenPoint([4.1, 0.2], edge, joints, 1, 0.5); assert.ok(Math.abs(p[0] - 4) < 1e-9); }); it('snapOpenPoint projects both rims of a thick wall onto the same boundary axis', () => { const edge = [5, 0, 5, 4]; const joints = [[5, 0], [5, 4]]; const insideRim = snapOpenPoint([4.25, 2.2], edge, joints, 1, 0.5); const outsideRim = snapOpenPoint([5.75, 2.2], edge, joints, 1, 0.5); assert.deepEqual(insideRim, outsideRim); assert.deepEqual(insideRim, [5, 2]); }); it('resolveOpenCuts: legacy open_to expands to full shared boundary', () => { const rooms = [ { id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] }, { id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]], open_to: ['a'] }, ]; rooms[0].open_to = ['b']; const cuts = resolveOpenCuts(rooms, null, 1, eps); assert.equal(cuts.length, 1); assert.ok(Math.abs(cuts[0][0] - 5) < 1e-6); }); it('resolveOpenCuts: explicit spans win over legacy', () => { const rooms = [ { id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]], open_to: ['b'] }, { id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]], open_to: ['a'] }, ]; const spans = [spanToEntry([5, 0], [5, 2], 1)]; const cuts = resolveOpenCuts(rooms, spans, 1, eps); assert.equal(cuts.length, 1); assert.ok(Math.abs(cuts[0][3] - 2) < 1e-6); }); it('hitSharedWall vs hitOuterWall', () => { const rooms = [ { id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] }, { id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] }, ]; const sh = hitSharedWall([5, 2], rooms, 0.5, eps); assert.ok(sh); assert.equal(sh.a.id, 'a'); const outer = hitOuterWall([0, 2], rooms, 0.5, eps); assert.ok(outer); assert.equal(outer.room.id, 'a'); assert.equal(hitSharedWall([0, 2], rooms, 0.5, eps), null); }); it('clearThicknessUnderSpan drops matching wall keys', () => { const key = wallKey([5, 0], [5, 4], pitch); let walls = [{ key, cm: 20 }]; walls = clearThicknessUnderSpan(walls, [5, 1], [5, 2], pitch, 1); assert.equal(walls.length, 0); }); it('thicknessOnClose uses neighbour cm else default', () => { const neighKey = wallKey([5, 2], [5, 4], pitch); const walls = [{ key: neighKey, cm: 25 }]; const cm = thicknessOnClose( walls, [5, 0, 5, 2], [[5, 2, 5, 4]], pitch, 1, DRAW_WALL_DEFAULT_CM, ); assert.equal(cm, 25); const def = thicknessOnClose([], [5, 0, 5, 4], [], pitch, 1, DRAW_WALL_DEFAULT_CM); assert.equal(def, DRAW_WALL_DEFAULT_CM); }); it('purgeOpeningsOnSpan removes openings on the stretch', () => { const openings = [ { id: '1', x: 0.005, y: 0.002, angle: 90 }, // on x=5 if scale=1000 → wait normalised { id: '2', x: 0.9, y: 0.9, angle: 0 }, ]; // span in render: x=5, y=0..4 with scale 1000 → norm a=[0.005,0], b=[0.005,0.004] const span = [5, 0, 5, 4]; const left = purgeOpeningsOnSpan( [{ id: '1', x: 5 / 1000, y: 2 / 1000, angle: 90 }, { id: '2', x: 0.9, y: 0.9, angle: 0 }], span, 1000, 0.5, ); assert.equal(left.length, 1); assert.equal(left[0].id, '2'); }); it('pointOnOpenCut detects placement on virtual', () => { assert.equal(pointOnOpenCut(5, 2, 90, [[5, 0, 5, 4]], 0.5), true); assert.equal(pointOnOpenCut(0, 2, 90, [[5, 0, 5, 4]], 0.5), false); }); it('syncOpenToFromCuts links rooms that share a cut', () => { const cfg = [ { id: 'a', name: 'A' }, { id: 'b', name: 'B' }, ]; const model = [ { id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] }, { id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] }, ]; syncOpenToFromCuts(cfg, model, [[5, 0, 5, 2]], eps); assert.ok(cfg[0].open_to.includes('b')); assert.ok(cfg[1].open_to.includes('a')); syncOpenToFromCuts(cfg, model, [], eps); assert.equal(cfg[0].open_to, undefined); }); it('removeCut / hitOpenSpan', () => { const cuts = [[5, 0, 5, 2], [5, 2, 5, 4]]; assert.ok(hitOpenSpan([5, 1], cuts, 0.5)); const next = removeCut(cuts, [5, 0, 5, 2], eps); assert.equal(next.length, 1); }); it('Boundary resolver gives an open span priority over its solid shared edge', () => { const rooms = [ { id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] }, { id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] }, ]; const options = { openPull: 0.5, openEndCap: 0.2, ambiguity: 0.25, eps, solidPull: () => 0.5, }; assert.equal(resolveBoundaryTarget([5, 2], rooms, [[5, 1, 5, 3]], options).kind, 'open'); assert.equal(resolveBoundaryTarget([5, 3.8], rooms, [[5, 1, 5, 3]], options).kind, 'shared', 'a click beyond the longitudinal endpoint cap must not restore the dash'); }); it('Boundary resolver refuses an ambiguous room-pair junction', () => { const rooms = [ { id: 'left', poly: [[0, 0], [5, 0], [5, 10], [0, 10]] }, { id: 'top', poly: [[5, 0], [10, 0], [10, 5], [5, 5]] }, { id: 'bottom', poly: [[5, 5], [10, 5], [10, 10], [5, 10]] }, ]; const options = { openPull: 0.5, openEndCap: 0.2, ambiguity: 0.6, eps, solidPull: () => 0.5, }; assert.equal(resolveBoundaryTarget([5, 5], rooms, [], options).kind, 'ambiguous'); assert.equal(resolveBoundaryTarget([5, 4], rooms, [], options).kind, 'shared'); }); it('clipOpenSpansToShared clips drifted span onto shared edge', () => { const rooms = [ { id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] }, { id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] }, ]; // span slightly past the shared edge (y 0..5 while shared is 0..4) const drifted = [spanToEntry([5, 0], [5, 5], 1)]; const clipped = clipOpenSpansToShared(drifted, rooms, 1, eps); assert.equal(clipped.length, 1); const sg = [clipped[0].a[0], clipped[0].a[1], clipped[0].b[0], clipped[0].b[1]]; assert.ok(Math.max(sg[1], sg[3]) <= 4 + 1e-6, JSON.stringify(sg)); }); it('adjacent virtual pieces of the same room pair collapse to one span', () => { const rooms = [ { id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] }, { id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] }, ]; const spans = [ spanToEntry([5, 0], [5, 2], 1), spanToEntry([5, 4], [5, 2], 1), ]; const clipped = clipOpenSpansToShared(spans, rooms, 1, eps); assert.equal(clipped.length, 1); const sg = entryToSeg(clipped[0], 1); assert.deepEqual( [Math.min(sg[1], sg[3]), Math.max(sg[1], sg[3])], [0, 4], ); assert.equal(resolveOpenCuts(rooms, spans, 1, eps).length, 1, 'legacy fragmented storage must render as one selectable span too'); }); it('clipOpenSpansToShared keeps both room-pair pieces after Split', () => { const rooms = [ { id: 'left_top', poly: [[0, 0], [5, 0], [5, 2], [0, 2]] }, { id: 'left_bottom', poly: [[0, 2], [5, 2], [5, 4], [0, 4]] }, { id: 'right', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] }, ]; const clipped = clipOpenSpansToShared( [spanToEntry([5, 1], [5, 3], 1)], rooms, 1, eps, ); const pieces = clipped.map((e) => entryToSeg(e, 1)) .map((sg) => [Math.min(sg[1], sg[3]), Math.max(sg[1], sg[3])]); assert.deepEqual(pieces, [[1, 2], [2, 3]]); const cfg = rooms.map(({ id }) => ({ id })); syncOpenToFromCuts(cfg, rooms, clipped.map((e) => entryToSeg(e, 1)), eps); assert.deepEqual(cfg[0].open_to, ['right']); assert.deepEqual(cfg[1].open_to, ['right']); assert.deepEqual(cfg[2].open_to, ['left_top', 'left_bottom']); }); it('entry round-trip', () => { const e = spanToEntry([100, 200], [100, 400], scale); const sg = entryToSeg(e, scale); assert.ok(Math.abs(sg[1] - 200) < 1e-6); assert.ok(Math.abs(sg[3] - 400) < 1e-6); }); // AUD-159B6-03: persisted data is untrusted input. A single malformed entry // used to throw inside render (`Cannot read properties of undefined`) and // blank the card for every reader of that space. it('sanitizeOpenSpans drops malformed entries and keeps the good ones', () => { const good = { a: [0.3, 0.14], b: [0.3, 0.46] }; const spans = [ null, 'x', 42, {}, { foo: 1 }, { a: [0.1, 0.2] }, { a: [0.1], b: [0.2, 0.3] }, { a: [0.1, Number.NaN], b: [0.2, 0.3] }, { a: [0.1, 0.2], b: [0.1, 0.2] }, // degenerate good, ]; assert.deepEqual(sanitizeOpenSpans(spans), [good]); assert.deepEqual(sanitizeOpenSpans(null), []); assert.deepEqual(sanitizeOpenSpans('nope'), []); }); it('malformed spans never reach the geometry helpers', () => { const rooms = [ { id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] }, { id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] }, ]; const junk = [{ foo: 1 }]; assert.deepEqual(resolveOpenCuts(rooms, junk, 1, eps), []); assert.deepEqual(degradeOpenSpans(junk, rooms, 1, eps), []); assert.deepEqual(clipOpenSpansToShared(junk, rooms, 1, eps), []); assert.deepEqual(rekeyOpenSpansAfterMove(junk, [], [], 1), []); }); it('resolveOpenCuts: legacy fallback can be refused mid-transaction', () => { const rooms = [ { id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]], open_to: ['b'] }, { id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]], open_to: ['a'] }, ]; assert.equal(resolveOpenCuts(rooms, [], 1, eps).length, 1); assert.equal(resolveOpenCuts(rooms, [], 1, eps, false).length, 0); }); it('setWallThickness still works with cleared list', () => { let w = setWallThickness([], [0, 0], [1, 0], 15, pitch, 1); assert.equal(w.length, 1); w = clearThicknessUnderSpan(w, [0, 0], [1, 0], pitch, 1); assert.equal(w.length, 0); }); });