Files
houseplan-card/test/geometry-corpus.test.mjs
CodexandCodex 733cd5b86b Три состояния владельца позиции вместо двух
Ревью r1, Medium: наблюдение о записи на удалённом пространстве говорило
«владелец жив» и про ключ, который ни во что не резолвится. Это заявление о
доказанности там, где её нет: продукт в этом состоянии ничего не удаляет не
потому, что владелец жив, а потому, что не знает — `space-reference-repair`
ведёт `live`, `absent` и `unverified`, три состояния, а не два.

Проверка ведёт те же три. Нерезолвящийся ключ получает наблюдение
`unknown_owner` с той же формулировкой, что и в живом пространстве: «владелец не
найден в конфигурации (возможно устройство HA)». Тест сверяет теперь и ТЕКСТ
причины — вид наблюдения без текста эту асимметрию пропускал, ровно так она и
проехала.

Issue: #566
User-Visible: no
2026-09-14 02:28:50 +03:00

382 lines
21 KiB
JavaScript

// Геометрический regression-корпус пользовательских планов (#560).
//
// Зачем он отдельно от 246 смоков и сотни модельных тестов. Каждый прежний
// тест ставит одно условие и проверяет одно следствие. Полевой план так не
// живёт: в нём короткие рёбра, T-узлы, пять разных толщин в одном
// пространстве, проём, упирающийся в вершину, и шум у узлов — одновременно, и
// проходит он не одну операцию, а цепочку import → Optimize → Optimize →
// Resize → save/reload → export. Ломается согласованность как раз на стыках
// (#253, #244, #252, #258), и ловил их до сих пор человек глазами.
//
// Оракулы здесь ЧИСЛЕННЫЕ и не зависят от рендера: инварианты модели,
// сходимость Optimize к фикс-пойнту, сохранность записей толщины, равенство
// после сериализации. Это прямое требование приёмки #560: корпус не имеет
// права принимать текущий рендер за эталон, иначе он узаконит любой регресс,
// который рендер отрисовал «похоже».
//
// Профиль поля, по которому подобраны условия (прочитан 13.09.2026 на
// установке владельца, только чтение, модель v10, rev 2740): 5 пространств,
// 37 комнат, 121 запись толщины, 35 проёмов, 7 перегородок, 156 маркеров;
// толщины по пространствам 20/22/28/29/33, 20/30/40, 4/15/17/33, 10/15;
// минимальное ребро комнаты 5 см при шаге сетки 1 см и 10 см при шаге 5 см;
// T-узлов 7/6/3/2 по пространствам, X-узлов НИ ОДНОГО; координат 1010, из них
// 23 с шумом ближе 1e-4 шага; почти осевых стен 0; один проём, чей край лежит
// ровно в вершине комнаты; в layout четыре записи на два УДАЛЁННЫХ
// пространства. Сама геометрия владельца в репозиторий не попадает: фикстуры
// синтетические и воспроизводят названные условия числами, а не планом дома.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { optimizePlans } from '../test-build/plan-optimizer.js';
import { applySafeResize, clampSafeResize, resolveSafeResize } 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';
import { classifyNearAxisSegment } from '../test-build/near-axis.js';
import {
checkReferences, checkWallKeys, checkMixedRoleRecords, checkHiddenObstacles,
checkPhysicalGeometry, checkWallRecordsPreserved, latticeProfile, wallKey,
} from '../scripts/model-invariants.mjs';
const STEP = 1 / 240; // шаг решётки в нормированных единицах
const clone = (value) => structuredClone(value);
const load = (name) => {
const raw = JSON.parse(readFileSync(
new URL(`./fixtures/560-corpus/${name}.json`, import.meta.url), 'utf8',
));
return { config: raw.config ?? raw, layout: raw.layout ?? {} };
};
/**
* Все нарушения инвариантов одним списком — тот же набор, что у CLI (#254).
* Наблюдения складываются в переданный массив: #566 перевёл часть случаев из
* нарушений в наблюдения, и корпус обязан отличать одно от другого.
*/
const violations = (config, layout = {}, notes = []) => [
...checkReferences({ config, layout }, { notes }),
...checkWallKeys(config, { notes }),
...checkMixedRoleRecords(config),
...checkHiddenObstacles(config),
...checkPhysicalGeometry(config),
];
const kinds = (list) => list.map((item) => `${item.invariant}/${item.kind}`).sort();
/** Степени узлов: сколько рёбер комнат приходит в каждую точку. */
function nodeDegrees(space) {
const degree = new Map();
for (const room of space.rooms || []) {
for (const point of room.poly || []) {
const key = `${point[0].toFixed(9)},${point[1].toFixed(9)}`;
degree.set(key, (degree.get(key) || 0) + 1);
}
}
return degree;
}
/** Длины рёбер комнат в шагах решётки. */
function edgeSteps(space) {
const out = [];
for (const room of space.rooms || []) {
const poly = room.poly || [];
for (let index = 0; index < poly.length; index++) {
const a = poly[index];
const b = poly[(index + 1) % poly.length];
out.push(Math.hypot(b[0] - a[0], b[1] - a[1]) / STEP);
}
}
return out;
}
/** Точки проёма: центр ± половина длины по его углу (angle в градусах). */
function openingEnds(opening) {
const radians = (opening.angle || 0) * Math.PI / 180;
const half = (opening.length || 0) / 2;
const dx = Math.cos(radians) * half;
const dy = Math.sin(radians) * half;
return [[opening.x - dx, opening.y - dy], [opening.x + dx, opening.y + dy]];
}
const roomVertices = (space) => (space.rooms || []).flatMap((room) => room.poly || []);
const near = (a, b, eps = 1e-9) => Math.hypot(a[0] - b[0], a[1] - b[1]) < eps;
/** Вход Resize в единицах NORM_W — та же подготовка, что у #281. */
const resizeInputs = (space) => ({
rooms: (space.rooms || []).map((room) => ({
id: room.id, poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
})),
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,
hosted: !!opening.host,
})),
obstacles: (space.partitions || []).map((partition) => ({
kind: 'segment',
a: partition.a.map((value) => value * NORM_W),
b: partition.b.map((value) => value * NORM_W),
half: wallCmToUnits(partition.cm, space.cell_cm, GRID_PITCH) / 2,
})),
});
const resizeOptions = (space, inputs, cm) => ({
minDim: wallCmToUnits(30, space.cell_cm, GRID_PITCH),
eps: GRID_PITCH * 0.05,
step: GRID_PITCH,
movingHalf: wallCmToUnits(cm, space.cell_cm, GRID_PITCH) / 2,
obstacles: inputs.obstacles,
});
/**
* Первая подвижная ручка ресайза на плане: комната, ребро, разрешённый шаг.
* Нужна именно подвижная — «включена, но ход нулевой» уже было дефектом #281.
*/
function firstMovableHandle(space) {
const inputs = resizeInputs(space);
for (const room of inputs.rooms) {
for (let edge = 0; edge < room.poly.length; edge++) {
const a = room.poly[edge];
const b = room.poly[(edge + 1) % room.poly.length];
const cm = thicknessCmAt(space.walls, a, b, STEP, NORM_W) || 15;
const options = resizeOptions(space, inputs, cm);
const resolution = resolveSafeResize(inputs.rooms, inputs.openings, room.id, edge, options);
if (!resolution.enabled) continue;
const outward = clampSafeResize(
inputs.rooms, inputs.openings, resolution.plan, GRID_PITCH, GRID_PITCH, options,
);
if (!outward) continue;
return { inputs, options, plan: resolution.plan, room: room.id, edge, d: outward };
}
}
return null;
}
/** Записать результат ресайза обратно в конфиг (единицы NORM_W → нормированные). */
function writeResize(space, moved) {
const next = clone(space);
for (const room of next.rooms || []) {
const poly = moved.polys[room.id];
if (poly) room.poly = poly.map(([x, y]) => [x / NORM_W, y / NORM_W]);
}
for (const opening of next.openings || []) {
const point = moved.openings[opening.id];
if (point) { opening.x = point[0] / NORM_W; opening.y = point[1] / NORM_W; }
}
return next;
}
/**
* Корпус. `condition` проверяется отдельным тестом: фикстура, потерявшая своё
* условие, — это зелёный прогон, который ничего не проверяет, и такой тихий
* успех в этом проекте уже дважды стоил дня (#171, #207).
*/
const CORPUS = [
{
file: 'c1-short-edges',
what: 'короткие рёбра: уступ в два шага (10 см при шаге 5 см)',
field: 'минимальное ребро комнаты в поле — 2 шага на первом этаже, 5 см на втором',
fixedPointOnImport: true,
condition(space) {
const short = edgeSteps(space).filter((steps) => steps <= 2.001);
assert.ok(short.length >= 2, `коротких рёбер ${short.length}, нужно ≥2`);
},
},
{
file: 'c2-t-junction-thicknesses',
what: 'T-узел, в котором сходятся записи трёх разных толщин',
field: 'в поле 7 T-узлов и пять толщин (20/22/28/29/33) в одном пространстве',
fixedPointOnImport: true,
condition(space) {
const tee = [...nodeDegrees(space).entries()].filter(([, degree]) => degree >= 3);
assert.ok(tee.length >= 1, 'T-узла нет');
const thicknesses = new Set((space.walls || []).map((wall) => wall.cm));
assert.ok(thicknesses.size >= 3, `толщин ${thicknesses.size}, нужно ≥3`);
},
},
{
file: 'c3-x-junction',
what: 'X-узел: четыре комнаты в одной точке',
field: 'в поле X-узлов НЕТ НИ ОДНОГО — это рискованный сосед, не наблюдение',
synthetic: true,
fixedPointOnImport: true,
condition(space) {
const cross = [...nodeDegrees(space).values()].filter((degree) => degree >= 4);
assert.ok(cross.length >= 1, 'узла степени 4 нет');
},
},
{
file: 'c4-opening-at-vertex',
what: 'проём, чей край лежит ровно в вершине комнаты',
field: 'в поле один такой проём',
fixedPointOnImport: true,
condition(space) {
const vertices = roomVertices(space);
const touching = (space.openings || []).filter((opening) => openingEnds(opening)
.some((end) => vertices.some((vertex) => near(vertex, end, STEP * 1e-3))));
assert.equal(touching.length, 1, 'проём должен упираться краем в вершину');
assert.ok(touching[0].host?.kind === 'wall' && touching[0].host.id,
'проём должен быть подвешен на сегмент стены');
},
},
{
file: 'c5-node-noise-near-axis',
what: 'шум у узла плюс честно наклонная стена',
field: 'в поле 23 координаты из 1010 лежат ближе 1e-4 шага от узла',
fixedPointOnImport: false, // именно шум Optimize и снимает
condition(space) {
assert.ok(latticeProfile({ config: { spaces: [space] } }).noise >= 1, 'шума у узла нет');
const slanted = (space.rooms || []).some((room) => {
const poly = room.poly || [];
return poly.some((a, index) => {
const b = poly[(index + 1) % poly.length];
const dx = Math.abs(b[0] - a[0]);
const dy = Math.abs(b[1] - a[1]);
return dx > STEP && dy > STEP * 0.5 && dy < dx; // наклон, но не осевой
});
});
assert.ok(slanted, 'наклонной стены нет');
},
},
{
file: 'c6-stale-layout',
what: 'записи layout на пространство, которого в конфиге больше нет',
field: 'в поле четыре такие записи на два удалённых пространства',
fixedPointOnImport: false,
// #566: владельца этих записей по конфигурации не видно, поэтому продукт
// хранит их намеренно, а инварианты сообщают о них НАБЛЮДЕНИЕМ. Нарушением
// была бы только запись, чей владелец отсутствует по самой конфигурации.
expectedViolations: [],
// Владельцев этих двух ключей по конфигурации не видно, поэтому наблюдение
// именно `unknown_owner`: «владелец жив» здесь было бы заявлением о
// доказанности, которой нет (r1 Medium).
expectedNotes: ['references/unknown_owner', 'references/unknown_owner'],
condition(space, layout) {
const known = new Set([space.id]);
const orphans = Object.values(layout).filter((entry) => !known.has(entry.s));
assert.equal(orphans.length, 2, 'осиротевших записей layout должно быть две');
},
},
];
for (const item of CORPUS) {
const label = `${item.file}${item.synthetic ? ' (синтетический)' : ''}`;
test(`корпус #560 ${label}: фикстура несёт своё условие — ${item.what}`, () => {
const { config, layout } = load(item.file);
assert.equal(config.model_version, 10, 'корпус ведётся на актуальной модели');
item.condition(config.spaces[0], layout);
});
test(`корпус #560 ${label}: импорт → Optimize → Optimize`, () => {
const { config, layout } = load(item.file);
const expected = item.expectedViolations ?? [];
const notes = [];
assert.deepEqual(kinds(violations(config, layout, notes)), expected.slice().sort(),
'импорт: инварианты');
assert.deepEqual(kinds(notes), (item.expectedNotes ?? []).slice().sort(),
'импорт: наблюдения названы ровно те, что заявлены');
const guard = clone(config);
const first = optimizePlans(config, layout);
assert.deepEqual(config, guard, 'Optimize не имеет права менять вход (#281)');
assert.deepEqual(kinds(violations(first.config, first.layout)), expected.slice().sort(),
'после Optimize новых нарушений нет');
assert.equal(first.changed, item.fixedPointOnImport ? false : true,
item.fixedPointOnImport
? 'приведённый план Optimize трогать не должен'
: 'фикстура заявлена как требующая приведения');
const second = optimizePlans(clone(first.config), clone(first.layout));
assert.equal(second.changed, false, 'второй Optimize — фикс-пойнт (#477)');
assert.deepEqual(second.config, first.config, 'второй Optimize ничего не двигает');
// Записи толщины — отдельный оракул: именно их теряли #253 и #258.
assert.deepEqual(
checkWallRecordsPreserved(config.spaces[0].walls, first.config.spaces[0].walls),
[], 'Optimize сохранил записи толщины',
);
});
/**
* Граница этой проверки названа прямо: перенос ЗАПИСЕЙ ТОЛЩИНЫ вслед за
* сдвинутым ребром делает не `resize.ts`, а хост карточки — `project()` в
* `ResizeController.move` возвращает `afterWalls`, и чистой функции для этого
* в модуле нет. Поэтому здесь проверяется то, что проверяемо без карточки:
* ручка действительно подвижна, геометрия после сдвига согласована, записи
* толщины целы, и Optimize сходится за ограниченное число проходов. Полный
* путь со штатным writer'ом проверяет браузерный смок корпуса.
*/
test(`корпус #560 ${label}: Resize ребра сохраняет согласованность`, (t) => {
const { config, layout } = load(item.file);
const optimized = optimizePlans(clone(config), clone(layout));
const space = optimized.config.spaces[0];
const handle = firstMovableHandle(space);
if (!handle) return t.skip('подвижной ручки ресайза на этом плане нет');
const moved = applySafeResize(handle.inputs.rooms, handle.inputs.openings, handle.plan, handle.d);
const resized = clone(optimized.config);
resized.spaces[0] = writeResize(space, moved);
const after = optimizePlans(clone(resized), clone(optimized.layout));
const expected = item.expectedViolations ?? [];
assert.deepEqual(kinds(violations(after.config, after.layout)), expected.slice().sort(),
'после Resize и Optimize новых нарушений нет');
assert.deepEqual(
checkWallRecordsPreserved(space.walls, after.config.spaces[0].walls),
[], 'ресайз не потерял ни одной толщины (#253)',
);
// Сходимость, а не «один проход»: вход здесь — рёбра сдвинуты, а записи
// толщины остались на прежних координатах, потому что их переносит хост.
// Из такого состояния Optimize имеет право убирать остатки в два приёма;
// важно, что он ОСТАНАВЛИВАЕТСЯ, а не переписывает план бесконечно.
let passes = 0;
let current = after;
while (current.changed && passes < 5) {
current = optimizePlans(clone(current.config), clone(current.layout));
passes += 1;
}
assert.equal(current.changed, false, `Optimize не сошёлся за ${passes} проходов`);
assert.ok(passes <= 2, `после Resize Optimize сходится за ${passes} проходов, ожидалось ≤2`);
assert.deepEqual(kinds(violations(current.config, current.layout)), expected.slice().sort(),
'сошедшийся план согласован');
});
test(`корпус #560 ${label}: сохранение и перечитывание плана`, () => {
const { config, layout } = load(item.file);
const optimized = optimizePlans(clone(config), clone(layout));
const reloaded = JSON.parse(JSON.stringify(optimized.config));
assert.deepEqual(reloaded, optimized.config, 'план переживает сериализацию без потерь');
const again = optimizePlans(reloaded, JSON.parse(JSON.stringify(optimized.layout)));
assert.equal(again.changed, false, 'перечитанный план не требует приведения');
});
}
test('корпус #560: ключи записей толщины канонические', () => {
for (const item of CORPUS) {
const { config } = load(item.file);
if (!item.fixedPointOnImport) continue; // c5/c6 приводит сам Optimize
for (const wall of config.spaces[0].walls || []) {
assert.equal(wall.key, wallKey(wall.a, wall.b),
`${item.file}: ключ записи разошёлся с координатами`);
}
}
});
test('корпус #560: наклонная стена не считается почти осевой', () => {
// Прямая проверка приёмки «корректная планировка не исправляется ради
// удобства рендера»: честный наклон 1 шаг на 60 не должен попадать в класс,
// который Optimize канонизирует. Шум у узла — попадает, и это правильно.
const { config } = load('c5-node-noise-near-axis');
const slanted = config.spaces[0].rooms.find((room) => room.id === 'c5-r2');
const [a, b] = [slanted.poly[0], slanted.poly[1]];
assert.equal(classifyNearAxisSegment(a, b), null, 'наклон не классифицируется как почти осевой');
const optimized = optimizePlans(clone(config), {});
const after = optimized.config.spaces[0].rooms.find((room) => room.id === 'c5-r2');
assert.deepEqual(after.poly[0], a, 'Optimize не выпрямил наклонную стену');
assert.deepEqual(after.poly[1], b, 'Optimize не выпрямил наклонную стену');
});