mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
207 lines
8.7 KiB
JavaScript
207 lines
8.7 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
|
import {
|
|
auditSafeResizeEligibility,
|
|
} from '../test-build/resize.js';
|
|
import { GRID_PITCH, NORM_W } from '../test-build/space-geometry.js';
|
|
import {
|
|
thicknessCmAt, wallCmToUnits,
|
|
} from '../test-build/wall-thickness.js';
|
|
|
|
const fixture = (name) => JSON.parse(readFileSync(
|
|
new URL(`./fixtures/${name}`, import.meta.url), 'utf8',
|
|
));
|
|
const clone = (value) => structuredClone(value);
|
|
|
|
function optimizedSpace(name) {
|
|
const source = fixture(name).space;
|
|
return optimizePlans({ spaces: [clone(source)], markers: [], settings: {} }, {}).config.spaces[0];
|
|
}
|
|
|
|
function auditSpace(space) {
|
|
const cellCm = Number(space.cell_cm) > 0 ? Number(space.cell_cm) : 5;
|
|
const rooms = (space.rooms || []).map((room) => ({
|
|
id: room.id,
|
|
poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
|
|
wall_ids: Array.isArray(room.wall_ids) ? [...room.wall_ids] : undefined,
|
|
}));
|
|
const openings = (space.openings || []).map((opening) => ({
|
|
id: opening.id,
|
|
x: opening.x * NORM_W,
|
|
y: opening.y * NORM_W,
|
|
length: opening.length * NORM_W,
|
|
angle: opening.angle,
|
|
type: opening.type,
|
|
hosted: !!opening.host,
|
|
}));
|
|
const obstacles = [];
|
|
for (const partition of space.partitions || []) {
|
|
obstacles.push({
|
|
kind: 'segment',
|
|
a: partition.a.map((value) => value * NORM_W),
|
|
b: partition.b.map((value) => value * NORM_W),
|
|
half: wallCmToUnits(partition.cm, cellCm, GRID_PITCH) / 2,
|
|
});
|
|
}
|
|
for (const draft of space.room_drafts || []) {
|
|
for (let index = 0; index + 1 < draft.points.length; index++) {
|
|
obstacles.push({
|
|
kind: 'segment',
|
|
a: draft.points[index].map((value) => value * NORM_W),
|
|
b: draft.points[index + 1].map((value) => value * NORM_W),
|
|
half: wallCmToUnits(Number(draft.segments?.[index]?.cm) || 15, cellCm, GRID_PITCH) / 2,
|
|
});
|
|
}
|
|
}
|
|
for (const column of space.wall_columns || []) {
|
|
const half = wallCmToUnits(column.cm, cellCm, GRID_PITCH) / 2;
|
|
obstacles.push({
|
|
kind: 'circle',
|
|
center: column.center.map((value) => value * NORM_W),
|
|
radius: column.shape === 'square' ? half * Math.SQRT2 : half,
|
|
});
|
|
}
|
|
const walls = (space.walls || []).filter((wall) =>
|
|
Array.isArray(wall.a) && Array.isArray(wall.b)
|
|
&& wall.a.length >= 2 && wall.b.length >= 2);
|
|
const wallKeyPitch = GRID_PITCH / NORM_W;
|
|
const optionsFor = (_roomId, _edge, a, b) => {
|
|
const cm = thicknessCmAt(walls, a, b, wallKeyPitch, NORM_W);
|
|
const axis = Math.abs(a[0] - b[0]) <= GRID_PITCH * 0.05 ? 'v'
|
|
: Math.abs(a[1] - b[1]) <= GRID_PITCH * 0.05 ? 'h' : null;
|
|
const overlaps = walls.filter((wall) => {
|
|
const wa = [wall.a[0] * NORM_W, wall.a[1] * NORM_W];
|
|
const wb = [wall.b[0] * NORM_W, wall.b[1] * NORM_W];
|
|
if (axis === 'h') {
|
|
if (Math.abs(wa[1] - a[1]) > GRID_PITCH * 0.05
|
|
|| Math.abs(wb[1] - a[1]) > GRID_PITCH * 0.05) return false;
|
|
return Math.min(Math.max(a[0], b[0]), Math.max(wa[0], wb[0]))
|
|
- Math.max(Math.min(a[0], b[0]), Math.min(wa[0], wb[0])) > GRID_PITCH * 0.05;
|
|
}
|
|
if (axis === 'v') {
|
|
if (Math.abs(wa[0] - a[0]) > GRID_PITCH * 0.05
|
|
|| Math.abs(wb[0] - a[0]) > GRID_PITCH * 0.05) return false;
|
|
return Math.min(Math.max(a[1], b[1]), Math.max(wa[1], wb[1]))
|
|
- Math.max(Math.min(a[1], b[1]), Math.min(wa[1], wb[1])) > GRID_PITCH * 0.05;
|
|
}
|
|
return false;
|
|
});
|
|
const cms = new Set(overlaps.map((wall) => Number(wall.cm)).filter((value) => value > 0));
|
|
return {
|
|
minDim: wallCmToUnits(30, cellCm, GRID_PITCH),
|
|
eps: GRID_PITCH * 0.05,
|
|
step: GRID_PITCH,
|
|
movingHalf: cm > 0 ? wallCmToUnits(cm, cellCm, GRID_PITCH) / 2 : 0,
|
|
obstacles,
|
|
thicknessConflict: cms.size > 1,
|
|
};
|
|
};
|
|
return auditSafeResizeEligibility(rooms, openings, optionsFor);
|
|
}
|
|
|
|
const EXPECTED = {
|
|
'real-plan-first-floor.json': {
|
|
total: 38,
|
|
enabled: 8,
|
|
disabled: {
|
|
diagonal: 0, 'side-angle': 0, 'duplicate-physical-wall': 4,
|
|
'partial-shared': 15, 'unequal-shared': 10, 'multiple-rooms': 0,
|
|
'thickness-conflict': 1, 'opening-conflict': 0, 'invalid-geometry': 0,
|
|
},
|
|
byRoom: {
|
|
'room-a': 'partial-shared enabled partial-shared unequal-shared duplicate-physical-wall unequal-shared unequal-shared enabled unequal-shared duplicate-physical-wall',
|
|
'room-b': 'enabled duplicate-physical-wall unequal-shared partial-shared',
|
|
'room-c': 'enabled unequal-shared unequal-shared partial-shared',
|
|
'room-d': 'partial-shared partial-shared enabled partial-shared',
|
|
'room-e': 'partial-shared enabled partial-shared partial-shared',
|
|
'room-f': 'unequal-shared partial-shared partial-shared partial-shared',
|
|
'room-g': 'partial-shared unequal-shared enabled unequal-shared',
|
|
'room-h': 'partial-shared enabled thickness-conflict duplicate-physical-wall',
|
|
},
|
|
idDigest: 'bac14112c65ddbabd3e92867d3db722eeb6f5e9a54c56af8c29994e3e69345ec',
|
|
},
|
|
'real-plan-second-floor.json': {
|
|
total: 36,
|
|
enabled: 9,
|
|
disabled: {
|
|
diagonal: 0, 'side-angle': 0, 'duplicate-physical-wall': 0,
|
|
'partial-shared': 19, 'unequal-shared': 8, 'multiple-rooms': 0,
|
|
'thickness-conflict': 0, 'opening-conflict': 0, 'invalid-geometry': 0,
|
|
},
|
|
byRoom: {
|
|
'room-a': 'partial-shared partial-shared enabled partial-shared',
|
|
'room-b': 'partial-shared unequal-shared enabled partial-shared',
|
|
'room-c': 'partial-shared enabled unequal-shared partial-shared',
|
|
'room-d': 'partial-shared enabled partial-shared enabled',
|
|
'room-e': 'partial-shared unequal-shared unequal-shared enabled',
|
|
'room-f': 'unequal-shared enabled partial-shared partial-shared',
|
|
'room-g': 'enabled unequal-shared partial-shared partial-shared',
|
|
'room-h': 'partial-shared unequal-shared partial-shared enabled partial-shared unequal-shared partial-shared partial-shared',
|
|
},
|
|
idDigest: '8eb10ad91a2d8bb9d6804d5e08ee2c7ed65b9839c58109d0107ed270378ef566',
|
|
},
|
|
};
|
|
|
|
function statusByRoom(audit) {
|
|
const result = {};
|
|
for (const handle of audit.handles) {
|
|
const status = handle.resolution.enabled ? 'enabled' : handle.resolution.reason;
|
|
(result[handle.roomId] ||= [])[handle.edge] = status;
|
|
}
|
|
return Object.fromEntries(Object.entries(result).map(([roomId, statuses]) => [
|
|
roomId, statuses.join(' '),
|
|
]));
|
|
}
|
|
|
|
const handleLines = (audit) => audit.handles.map((handle) => (
|
|
`${handle.id}=${handle.resolution.enabled ? 'enabled' : handle.resolution.reason}`
|
|
));
|
|
|
|
function assertExactAudit(name, audit) {
|
|
const expected = EXPECTED[name];
|
|
const lines = handleLines(audit);
|
|
const details = `\nActual handles:\n${lines.join('\n')}`;
|
|
assert.deepEqual({ total: audit.total, enabled: audit.enabled, disabled: audit.disabled }, {
|
|
total: expected.total, enabled: expected.enabled, disabled: expected.disabled,
|
|
}, `reason-count baseline changed for ${name}${details}`);
|
|
assert.deepEqual(statusByRoom(audit), expected.byRoom,
|
|
`per-edge status baseline changed for ${name}${details}`);
|
|
const digest = createHash('sha256').update(lines.join('\n')).digest('hex');
|
|
assert.equal(digest, expected.idDigest,
|
|
`canonical endpoint/handle identity baseline changed for ${name}; actual digest ${digest}${details}`);
|
|
}
|
|
|
|
test('issue 292 measures exact post-Optimize availability on both real plans', () => {
|
|
for (const name of Object.keys(EXPECTED)) {
|
|
assertExactAudit(name, auditSpace(optimizedSpace(name)));
|
|
}
|
|
});
|
|
|
|
test('issue 292 known second-floor shared wall has equivalent enabled owner handles', () => {
|
|
const audit = auditSpace(optimizedSpace('real-plan-second-floor.json'));
|
|
for (const [roomId, edge] of [['room-a', 2], ['room-b', 2]]) {
|
|
const handle = audit.handles.find((entry) => entry.roomId === roomId && entry.edge === edge);
|
|
assert.ok(handle?.resolution.enabled, `${roomId}:${edge} must be enabled`);
|
|
assert.deepEqual(handle.resolution.plan.roomIds.slice().sort(), ['room-a', 'room-b']);
|
|
}
|
|
});
|
|
|
|
test('issue 292 Optimize removes the confirmed false near-axis reasons only', () => {
|
|
const raw = auditSpace(fixture('real-plan-second-floor.json').space);
|
|
const optimized = auditSpace(optimizedSpace('real-plan-second-floor.json'));
|
|
assert.deepEqual({
|
|
diagonal: raw.disabled.diagonal,
|
|
sideAngle: raw.disabled['side-angle'],
|
|
}, { diagonal: 2, sideAngle: 6 });
|
|
assert.deepEqual({
|
|
diagonal: optimized.disabled.diagonal,
|
|
sideAngle: optimized.disabled['side-angle'],
|
|
}, { diagonal: 0, sideAngle: 0 });
|
|
assert.equal(optimized.enabled, 9,
|
|
'identity-only collinear atoms must not add, remove or enable Resize gestures');
|
|
});
|