Files
houseplan-card/test/resize-availability-audit.test.mjs
T
2026-08-26 02:20:57 +03:00

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