Files
houseplan-card/test/open-spans.test.mjs
T

274 lines
11 KiB
JavaScript

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);
});
});