mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
274 lines
11 KiB
JavaScript
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);
|
|
});
|
|
});
|