Files
houseplan-card/test/partition-openings.test.mjs
2026-08-19 15:43:23 +03:00

204 lines
9.6 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
hostedOpeningIntervalsOverlap,
materializePartitionOpening,
partitionOpeningCut,
partitionOpeningFace,
partitionOpeningHasCompositeRoomWall,
partitionOpeningJambMargin,
partitionOpeningNeedsStrictValidation,
resolvePartitionOpening,
resolvePartitionOpeningStrict,
} from '../test-build/partition-openings.js';
import { cutPartitionBody, partitionBody } from '../test-build/physical-geometry.js';
import { physicalBodyParts } from '../test-build/physical-geometry.js';
const partition = { id: 'p1', a: [0, 0], b: [100, 0], cm: 15 };
const opening = (patch = {}) => ({
id: 'o1', type: 'door', x: 0.5, y: 0, angle: 0, length: 0.3,
host: { kind: 'partition', id: 'p1', t: 0.5 },
...patch,
});
test('explicit host resolves authoritative centre, angle, depth and compatibility projection', () => {
const resolution = resolvePartitionOpening(opening(), [partition], 100, 5, 5);
assert.equal(resolution.reason, null);
assert.deepEqual(resolution.resolved.center, [50, 0]);
assert.equal(resolution.resolved.length, 30);
assert.equal(resolution.resolved.depth, 15);
assert.deepEqual(materializePartitionOpening(
{ ...opening(), x: 9, y: 9, angle: 77 }, resolution.resolved, 100,
), { ...opening(), x: 0.5, y: 0, angle: 0 });
});
test('reversed host direction preserves directed t and canonical symbol angle', () => {
const reversed = { ...partition, a: [100, 0], b: [0, 0] };
const resolved = resolvePartitionOpening(opening({
host: { kind: 'partition', id: 'p1', t: 0.25 },
}), [reversed], 100, 5, 5).resolved;
assert.deepEqual(resolved.center, [75, 0]);
assert.equal(resolved.angle, 0);
assert.equal(partitionOpeningFace(resolved, false).cm, 15);
assert.equal(partitionOpeningFace(resolved, true).side, 1);
});
test('missing, out-of-range and too-short hosts fail dark', () => {
assert.equal(resolvePartitionOpening(opening(), [], 100).reason, 'missing-partition');
assert.equal(resolvePartitionOpening(opening({
host: { kind: 'partition', id: 'p1', t: 2 },
}), [partition], 100).reason, 'invalid-position');
assert.equal(resolvePartitionOpening(opening({ length: 1.1 }), [partition], 100).reason,
'does-not-fit');
});
test('strict writes reserve half the real wall depth while compat reads remain visible', () => {
assert.equal(partitionOpeningJambMargin(partition, 5, 5), 7.5);
for (const type of ['door', 'window', 'gate', 'passage']) {
const atBoundary = opening({ type, host: { kind: 'partition', id: 'p1', t: 0.225 } });
const insideJamb = opening({ type, host: { kind: 'partition', id: 'p1', t: 0.224 } });
assert.ok(resolvePartitionOpeningStrict(atBoundary, [partition], 100, 5, 5).resolved);
assert.equal(
resolvePartitionOpeningStrict(insideJamb, [partition], 100, 5, 5).reason,
'does-not-fit-jamb',
);
assert.ok(resolvePartitionOpening(insideJamb, [partition], 100, 5, 5).resolved,
`compat/read policy must not orphan a stored near-end ${type}`);
}
});
test('strict jamb policy follows thickness, scale and host direction', () => {
for (const cm of [1, 15, 100]) {
for (const [cellCm, gridPitch] of [[5, 5], [2.5, 8]]) {
for (const [a, b] of [[[0, 0], [1000, 0]], [[1000, 1000], [0, 0]]]) {
const host = { id: 'p1', a, b, cm };
const span = Math.hypot(b[0] - a[0], b[1] - a[1]);
const margin = partitionOpeningJambMargin(host, cellCm, gridPitch);
const length = 200;
const exactT = (length / 2 + margin) / span;
const candidate = opening({
length: length / 100,
host: { kind: 'partition', id: 'p1', t: exactT },
});
assert.ok(resolvePartitionOpeningStrict(
candidate, [host], 100, cellCm, gridPitch,
).resolved, `${cm}/${cellCm}/${gridPitch} exact boundary`);
assert.equal(resolvePartitionOpeningStrict({
...candidate, host: { ...candidate.host, t: exactT - 1e-5 },
}, [host], 100, cellCm, gridPitch).reason, 'does-not-fit-jamb');
}
}
}
});
test('only direct hosted geometry changes opt an existing opening into strict validation', () => {
const original = opening();
assert.equal(partitionOpeningNeedsStrictValidation(original, { ...original, type: 'window' }), false);
assert.equal(partitionOpeningNeedsStrictValidation(original, {
...original, host: { ...original.host, t: 0.4 },
}), true);
assert.equal(partitionOpeningNeedsStrictValidation(original, { ...original, length: 0.4 }), true);
assert.equal(partitionOpeningNeedsStrictValidation(null, original), true);
});
test('partition body is split by its own full-depth slot only', () => {
const body = partitionBody(partition.a, partition.b, partition.cm, 5, 5);
const resolved = resolvePartitionOpening(opening(), [partition], 100, 5, 5).resolved;
const pieces = cutPartitionBody(body, [partitionOpeningCut(resolved)]);
assert.equal(pieces.length, 2);
const ranges = pieces.map((piece) => [
Math.min(...piece.map((point) => point[0])),
Math.max(...piece.map((point) => point[0])),
]).sort((a, b) => a[0] - b[0]);
assert.ok(ranges[0][1] <= 35 + 1e-6);
assert.ok(ranges[1][0] >= 65 - 1e-6);
});
test('full-depth cut is stable for 1/15/100 cm and diagonal hosts', () => {
for (const cm of [1, 15, 100]) {
const diagonal = { id: 'p1', a: [0, 0], b: [100, 100], cm };
const resolved = resolvePartitionOpening(opening(), [diagonal], 100, 5, 5).resolved;
const body = partitionBody(diagonal.a, diagonal.b, cm, 5, 5);
const pieces = cutPartitionBody(body, [partitionOpeningCut(resolved)]);
assert.equal(pieces.length, 2, `${cm} cm diagonal host keeps two jamb bodies`);
}
});
test('computed junction patches cannot bridge a hosted slot', () => {
// Ветка упирается в СЕРЕДИНУ пролёта p1: оба узловых патча T-стыка лежат в
// полосе перегородки (x∈[42.5,57.5], y∈[-7.5,0]) и целиком накрываются
// слотом проёма по умолчанию (t=0.5, длина 30 → x∈[35,65]). Прежняя
// фикстура вела ветку в конец p1 — её патч жил вне хоста (x>100), контрольная
// точка [92,0] была вне патча при любом поведении кода, и тест не умел
// падать (#188).
const space = {
partitions: [partition, { id: 'branch', a: [50, 0], b: [50, 60], cm: 15 }],
room_drafts: [], wall_columns: [],
};
const resolved = resolvePartitionOpening(opening(), [partition], 100, 5, 5).resolved;
const pointIn = (point, body) => {
let inside = false;
for (let i = 0, j = body.length - 1; i < body.length; j = i++) {
const a = body[i], b = body[j];
if (((a[1] > point[1]) !== (b[1] > point[1]))
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1]))
/ ((b[1] - a[1]) || 1e-12) + a[0]) inside = !inside;
}
return inside;
};
const probes = [[47, -3], [53, -3]];
// Контроль фальсифицируемости: без разреза те же патчи обязаны мостить
// слот. Если геометрия фикстуры перестанет их порождать, краснеет этот
// контроль, а не молча обесценивается проверка ниже.
const uncut = physicalBodyParts(space, 5, 5, 0.001);
for (const probe of probes) {
assert.equal(uncut.patches.some((body) => pointIn(probe, body)), true,
`uncut junction patch must cover ${JSON.stringify(probe)}`);
}
const cut = physicalBodyParts(space, 5, 5, 0.001, [partitionOpeningCut(resolved)]);
for (const probe of probes) {
assert.equal(cut.patches.some((body) => pointIn(probe, body)), false,
`cut junction patch must not bridge the slot at ${JSON.stringify(probe)}`);
}
});
test('composite cut requires exact collinearity and full interval coverage', () => {
const resolved = resolvePartitionOpening(opening(), [partition], 100, 5, 5).resolved;
const interval = (patch = {}) => ({
roomId: 'r', a: [0, 0], b: [100, 0], key: 'wall',
kind: 'outer', cm: 15, open: false, half: 7.5, ...patch,
});
assert.equal(partitionOpeningHasCompositeRoomWall(resolved, [interval()], 0.01), true);
assert.equal(partitionOpeningHasCompositeRoomWall(
resolved, [interval({ a: [0, 1], b: [100, 1] })], 0.01,
), false);
assert.equal(partitionOpeningHasCompositeRoomWall(
resolved, [interval({ a: [0, 0], b: [40, 0] })], 0.01,
), false);
assert.equal(partitionOpeningHasCompositeRoomWall(
resolved, [interval({ a: [50, -50], b: [50, 50] })], 0.01,
), false);
assert.equal(partitionOpeningHasCompositeRoomWall(resolved, [
interval({ a: [0, 0], b: [50, 0] }),
interval({ a: [50, 0], b: [100, 0] }),
], 0.01), true, 'adjacent atomic room intervals jointly cover the opening');
assert.equal(partitionOpeningHasCompositeRoomWall(resolved, [
interval({ a: [0, 0], b: [49, 0] }),
interval({ a: [51, 0], b: [100, 0] }),
], 0.01), false, 'a real coverage gap is not a composite wall');
});
test('overlap reservation is scoped to one explicit host', () => {
const first = resolvePartitionOpening(opening(), [partition], 100).resolved;
const overlapping = resolvePartitionOpening(opening({
id: 'o2', host: { kind: 'partition', id: 'p1', t: 0.6 },
}), [partition], 100).resolved;
const clear = resolvePartitionOpening(opening({
id: 'o3', length: 0.1, host: { kind: 'partition', id: 'p1', t: 0.9 },
}), [partition], 100).resolved;
assert.equal(hostedOpeningIntervalsOverlap(first, [overlapping]), true);
assert.equal(hostedOpeningIntervalsOverlap(first, [clear]), false);
});