mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
304 lines
12 KiB
JavaScript
304 lines
12 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
buildHiddenWallDiagnosticGeometry,
|
|
buildPlanSnapGeometry,
|
|
findSharedRoomSnapSegment,
|
|
resolvePlanSnap,
|
|
resolvePlanSnapResult,
|
|
resolveStrictPlanSnap,
|
|
} from '../test-build/plan-snap-overlay.js';
|
|
|
|
const space = (patch = {}) => ({ rooms: [], partitions: [], ...patch });
|
|
const closePoint = (actual, expected, epsilon = 1e-6) => {
|
|
assert.ok(Math.abs(actual[0] - expected[0]) <= epsilon, `${actual[0]} != ${expected[0]}`);
|
|
assert.ok(Math.abs(actual[1] - expected[1]) <= epsilon, `${actual[1]} != ${expected[1]}`);
|
|
};
|
|
|
|
test('collector includes room rectangles, polygons and ordinary partitions', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({
|
|
rooms: [
|
|
{ id: 'rect', x: 0, y: 0, w: 100, h: 50 },
|
|
{ id: 'poly', poly: [[200, 0], [250, 0], [225, 50]] },
|
|
],
|
|
partitions: [{ id: 'partition', a: [100, 100], b: [150, 100], cm: 10 }],
|
|
}),
|
|
});
|
|
assert.equal(geometry.segments.length, 8);
|
|
assert.ok(geometry.endpoints.some((entry) => entry.point[0] === 150 && entry.point[1] === 100));
|
|
});
|
|
|
|
test('room with two coincident extended partitions keeps six structural endpoints', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({
|
|
rooms: [{
|
|
id: 'room', area: null,
|
|
poly: [[200, 200], [600, 200], [600, 600], [200, 600]],
|
|
}],
|
|
partitions: [
|
|
{ id: 'vertical', a: [200, 50], b: [200, 600], cm: 20 },
|
|
{ id: 'horizontal', a: [200, 600], b: [900, 600], cm: 20 },
|
|
],
|
|
}),
|
|
});
|
|
assert.equal(geometry.segments.length, 6);
|
|
assert.deepEqual(geometry.endpoints.map((entry) => entry.point)
|
|
.sort((left, right) => left[0] - right[0] || left[1] - right[1]), [
|
|
[200, 50],
|
|
[200, 200],
|
|
[200, 600],
|
|
[600, 200],
|
|
[600, 600],
|
|
[900, 600],
|
|
]);
|
|
});
|
|
|
|
test('coincident endpoints and axes are deduplicated independently of direction', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({
|
|
partitions: [
|
|
{ id: 'a', a: [0, 0], b: [100, 0], cm: 10 },
|
|
{ id: 'b', a: [100, 0], b: [0, 0], cm: 10 },
|
|
{ id: 'c', a: [100, 0], b: [100, 100], cm: 10 },
|
|
],
|
|
}),
|
|
});
|
|
assert.equal(geometry.segments.length, 2);
|
|
assert.equal(geometry.endpoints.length, 3);
|
|
});
|
|
|
|
test('degenerate partitions are excluded while ordinary walls remain snap targets', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({
|
|
partitions: [
|
|
{ id: 'saved', a: [0, 10], b: [100, 10], cm: 15 },
|
|
{ id: 'zero', a: [20, 20], b: [20, 20], cm: 10 },
|
|
],
|
|
}),
|
|
});
|
|
assert.equal(geometry.segments.length, 1);
|
|
assert.equal(geometry.segments[0].sourceId, 'saved');
|
|
});
|
|
|
|
test('room cuts leave solid intervals but do not create cut-boundary endpoints', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({ rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }] }),
|
|
roomCuts: [[30, 0, 70, 0], [0, 60, 0, 100]],
|
|
});
|
|
assert.ok(geometry.segments.some((segment) => segment.a[0] === 0 && segment.b[0] === 30));
|
|
assert.ok(geometry.segments.some((segment) => segment.a[0] === 70 && segment.b[0] === 100));
|
|
assert.ok(!geometry.endpoints.some((entry) => entry.point[0] === 30 && entry.point[1] === 0));
|
|
assert.ok(!geometry.endpoints.some((entry) => entry.point[0] === 70 && entry.point[1] === 0));
|
|
assert.ok(geometry.endpoints.some((entry) => entry.point[0] === 0 && entry.point[1] === 100),
|
|
'an original endpoint remains when another solid wall still meets it');
|
|
});
|
|
|
|
test('a hosted opening cuts only its presentation partition axis without boundary nodes', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({
|
|
partitions: [
|
|
{ id: 'host', a: [0, 0], b: [100, 0], cm: 10 },
|
|
{ id: 'nearby', a: [0, 20], b: [100, 20], cm: 10 },
|
|
{ id: 'crossing', a: [50, -50], b: [50, 50], cm: 10 },
|
|
],
|
|
}),
|
|
partitionCuts: [{ hostId: 'host', a: [40, 0], b: [60, 0] }],
|
|
});
|
|
const host = geometry.segments.filter((segment) => segment.sourceId === 'host');
|
|
assert.deepEqual(host.map((segment) => [segment.a, segment.b]), [
|
|
[[0, 0], [40, 0]],
|
|
[[60, 0], [100, 0]],
|
|
]);
|
|
assert.ok(!geometry.endpoints.some((entry) => entry.point[0] === 40 && entry.point[1] === 0));
|
|
assert.ok(!geometry.endpoints.some((entry) => entry.point[0] === 60 && entry.point[1] === 0));
|
|
assert.ok(geometry.segments.some((segment) => segment.sourceId === 'nearby'
|
|
&& segment.a[0] === 0 && segment.b[0] === 100));
|
|
assert.ok(geometry.segments.some((segment) => segment.sourceId === 'crossing'
|
|
&& segment.a[1] === -50 && segment.b[1] === 50));
|
|
assert.equal(resolvePlanSnap(geometry, [55, 3], { tolerance: 4, gridStep: 10 }), null);
|
|
});
|
|
|
|
test('partition axes remain continuous when hosted cuts are omitted from a structural snapshot', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({ partitions: [{ id: 'host', a: [0, 0], b: [100, 0], cm: 10 }] }),
|
|
});
|
|
assert.deepEqual(geometry.segments.map((segment) => [segment.a, segment.b]), [
|
|
[[0, 0], [100, 0]],
|
|
]);
|
|
assert.equal(resolvePlanSnap(geometry, [50, 4], { tolerance: 5, gridStep: 10 })?.kind, 'line');
|
|
});
|
|
|
|
test('shared-room interval contains endpoints and interior wall-bound points only on one edge', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({ rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }] }),
|
|
});
|
|
assert.ok(findSharedRoomSnapSegment(geometry, [0, 0], [100, 0]));
|
|
assert.ok(findSharedRoomSnapSegment(geometry, [0, 0], [40, 0]));
|
|
assert.ok(findSharedRoomSnapSegment(geometry, [20, 0], [80, 0]));
|
|
assert.equal(findSharedRoomSnapSegment(geometry, [0, 0], [100, 100]), null,
|
|
'different room edges never imply an auto-closing wall');
|
|
assert.equal(findSharedRoomSnapSegment(geometry, [40, 0], [40, 0]), null,
|
|
'one point cannot define a closing interval');
|
|
assert.equal(findSharedRoomSnapSegment(geometry, [-1, 0], [40, 0], 0.001), null,
|
|
'collinearity outside the closed segment is insufficient');
|
|
});
|
|
|
|
test('shared-room interval respects cuts and rejects partition-only axes', () => {
|
|
const cut = buildPlanSnapGeometry({
|
|
space: space({ rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }] }),
|
|
roomCuts: [[40, 0, 60, 0]],
|
|
});
|
|
assert.ok(findSharedRoomSnapSegment(cut, [0, 0], [30, 0]));
|
|
assert.equal(findSharedRoomSnapSegment(cut, [0, 0], [100, 0]), null,
|
|
'opening or open-span cuts split eligibility');
|
|
|
|
const independent = buildPlanSnapGeometry({
|
|
space: space({
|
|
partitions: [{ id: 'partition', a: [0, 20], b: [100, 20], cm: 15 }],
|
|
}),
|
|
});
|
|
assert.equal(findSharedRoomSnapSegment(independent, [0, 20], [100, 20]), null);
|
|
});
|
|
|
|
test('a completed room remains the authority for a coincident deduplicated axis', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({
|
|
rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }],
|
|
partitions: [{ id: 'partition', a: [100, 0], b: [0, 0], cm: 15 }],
|
|
}),
|
|
});
|
|
const shared = findSharedRoomSnapSegment(geometry, [0, 0], [100, 0]);
|
|
assert.equal(shared?.sourceKind, 'room');
|
|
});
|
|
|
|
test('issue 296 diagnostic projection preserves hidden independent source identity', () => {
|
|
const input = space({
|
|
rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }],
|
|
partitions: [
|
|
{ id: 'hidden', a: [0, 0], b: [100, 0], cm: 15 },
|
|
{ id: 'free', a: [0, 50], b: [100, 50], cm: 15 },
|
|
],
|
|
});
|
|
const diagnostic = buildHiddenWallDiagnosticGeometry({ space: input });
|
|
assert.deepEqual(diagnostic.segments.map((segment) => segment.sourceId), ['hidden']);
|
|
assert.equal(diagnostic.endpoints.length, 2,
|
|
'source endpoints must not be deduplicated into room authority');
|
|
assert.deepEqual(new Set(diagnostic.endpoints.map((endpoint) => endpoint.sourceId)),
|
|
new Set(['hidden']));
|
|
assert.equal(diagnostic.segments.some((segment) => segment.sourceId === 'free'), false);
|
|
|
|
const snap = buildPlanSnapGeometry({ space: input });
|
|
assert.equal(findSharedRoomSnapSegment(snap, [0, 0], [100, 0])?.sourceKind, 'room',
|
|
'diagnostics must not change the established snap authority');
|
|
});
|
|
|
|
test('issue 296 point-only contacts are not diagnostic walls', () => {
|
|
const diagnostic = buildHiddenWallDiagnosticGeometry({
|
|
space: space({
|
|
rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }],
|
|
partitions: [{ id: 'touch', a: [100, 0], b: [150, 0], cm: 15 }],
|
|
}),
|
|
});
|
|
assert.deepEqual(diagnostic, { segments: [], endpoints: [] });
|
|
});
|
|
|
|
test('endpoint wins over a closer line and tie resolution is stable', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({
|
|
partitions: [
|
|
{ id: 'endpoint', a: [0, 0], b: [100, 0], cm: 10 },
|
|
{ id: 'closer-line', a: [8, -100], b: [8, 100], cm: 10 },
|
|
],
|
|
}),
|
|
});
|
|
const endpoint = resolvePlanSnap(geometry, [7, 1], { tolerance: 12, gridStep: 10 });
|
|
assert.equal(endpoint?.kind, 'endpoint');
|
|
closePoint(endpoint.point, [0, 0]);
|
|
|
|
const tie = buildPlanSnapGeometry({
|
|
space: space({ partitions: [
|
|
{ id: 'right', a: [10, 0], b: [10, 100], cm: 10 },
|
|
{ id: 'left', a: [-10, 0], b: [-10, 100], cm: 10 },
|
|
] }),
|
|
});
|
|
const first = resolvePlanSnap(tie, [0, 50], { tolerance: 12, gridStep: 10 });
|
|
const second = resolvePlanSnap({
|
|
segments: [...tie.segments].reverse(), endpoints: [...tie.endpoints].reverse(),
|
|
}, [0, 50], { tolerance: 12, gridStep: 10 });
|
|
assert.equal(first?.key, second?.key);
|
|
});
|
|
|
|
test('line projection stays wall-bound and quantizes along horizontal, vertical and diagonal axes', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({ partitions: [
|
|
{ id: 'horizontal', a: [0, 0], b: [100, 0], cm: 10 },
|
|
{ id: 'vertical', a: [200, 0], b: [200, 100], cm: 10 },
|
|
{ id: 'diagonal', a: [300, 0], b: [400, 100], cm: 10 },
|
|
] }),
|
|
});
|
|
closePoint(resolvePlanSnap(geometry, [44, 5], { tolerance: 8, gridStep: 10 }).point, [40, 0]);
|
|
closePoint(resolvePlanSnap(geometry, [205, 44], { tolerance: 8, gridStep: 10 }).point, [200, 40]);
|
|
const diagonal = resolvePlanSnap(geometry, [337, 43], { tolerance: 8, gridStep: 10 });
|
|
assert.equal(diagonal.kind, 'line');
|
|
closePoint(diagonal.point, [342.42640687119285, 42.426406871192846]);
|
|
});
|
|
|
|
test('current anchor is excluded while an explicit closure endpoint remains available', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({ partitions: [{ id: 'wall', a: [0, 0], b: [100, 0], cm: 10 }] }),
|
|
});
|
|
assert.equal(resolvePlanSnap(geometry, [0, 0], {
|
|
tolerance: 12, gridStep: 10, excludePoints: [[0, 0]],
|
|
}), null);
|
|
const closure = resolvePlanSnap(geometry, [2, 98], {
|
|
tolerance: 12,
|
|
gridStep: 10,
|
|
excludePoints: [[100, 100]],
|
|
extraEndpoints: [{ point: [0, 100], key: 'closure' }],
|
|
});
|
|
assert.equal(closure?.kind, 'endpoint');
|
|
closePoint(closure.point, [0, 100]);
|
|
});
|
|
|
|
test('visually inseparable distinct endpoints fail closed until zoom separates them', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({ partitions: [
|
|
{ id: 'a', a: [0, 0], b: [100, 0], cm: 10 },
|
|
{ id: 'b', a: [6, 0], b: [6, 100], cm: 10 },
|
|
] }),
|
|
});
|
|
const ambiguous = resolvePlanSnapResult(geometry, [3, 2], {
|
|
tolerance: 12, distinguishTolerance: 8, gridStep: 10,
|
|
});
|
|
assert.equal(ambiguous.kind, 'ambiguous');
|
|
assert.equal(ambiguous.conflicts.length, 2);
|
|
const zoomed = resolvePlanSnapResult(geometry, [1, 1], {
|
|
tolerance: 3, distinguishTolerance: 2, gridStep: 10,
|
|
});
|
|
assert.equal(zoomed.kind, 'resolved');
|
|
closePoint(zoomed.candidate.point, [0, 0]);
|
|
});
|
|
|
|
test('strict Shift accepts only points and intersections on the selected octant ray', () => {
|
|
const geometry = buildPlanSnapGeometry({
|
|
space: space({ partitions: [
|
|
{ id: 'off-ray', a: [100, 5], b: [100, 80], cm: 10 },
|
|
{ id: 'cross', a: [95, -50], b: [95, 50], cm: 10 },
|
|
{ id: 'diagonal', a: [200, 200], b: [300, 300], cm: 10 },
|
|
] }),
|
|
});
|
|
const horizontal = resolveStrictPlanSnap(geometry, [97, 3], {
|
|
anchor: [0, 0], tolerance: 12, distinguishTolerance: 8, gridStep: 10, epsilon: 0.001,
|
|
});
|
|
assert.equal(horizontal.kind, 'resolved');
|
|
assert.equal(horizontal.candidate.kind, 'line');
|
|
closePoint(horizontal.candidate.point, [95, 0]);
|
|
|
|
const diagonal = resolveStrictPlanSnap(geometry, [205, 201], {
|
|
anchor: [100, 100], tolerance: 12, distinguishTolerance: 8, gridStep: 10, epsilon: 0.001,
|
|
});
|
|
assert.equal(diagonal.kind, 'resolved');
|
|
closePoint(diagonal.candidate.point, [200, 200]);
|
|
});
|