test: check model invariants for references and wall records

Issue: #254
User-Visible: no
This commit is contained in:
Matysh
2026-08-23 09:34:32 +03:00
parent c8d7d282a4
commit 5cf52707c4
4 changed files with 421 additions and 0 deletions
+1
View File
@@ -9,6 +9,7 @@
"watch": "rollup -c --watch",
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs",
"invariants": "node scripts/model-invariants.mjs",
"docs:accept": "node scripts/docs-accept.mjs",
"smokes:select": "node scripts/smoke-select.mjs",
"inventory": "node scripts/inventory.mjs",
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env node
/**
* Инварианты модели плана (#254).
*
* npm run invariants -- --config <файл> # экспорт, config/get или сырой config
* npm run invariants -- --config <файл> --layout <файл>
* npm run invariants -- --config <файл> --json
*
* Зачем это существует. Самый дорогой класс дефектов проекта — не ошибки
* формул, а потеря согласованности между геометрией и ссылками на неё: #253
* (ресайз потерял запись толщины), #244 (маркеры на удалённые пространства),
* #252 (37 забытых позиций в layout), #248, #126. Каждый раз это находил
* человек глазами. Здесь те же вопросы задаются машинно и одинаково.
*
* Модуль сознательно не импортирует `src/**`: он должен читать сырой JSON
* экспорта и живого хранилища, не требуя сборки и не завися от того, что
* продуктовый код считает «правильным» сегодня.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
/** Доля шага сетки, в пределах которой запись считается лежащей на ребре. */
const EDGE_TOLERANCE = 0.004;
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
const point = (value) => (Array.isArray(value) && isFiniteNumber(value[0])
&& isFiniteNumber(value[1]) ? [value[0], value[1]] : null);
const roomPolygon = (room) => {
const poly = Array.isArray(room?.poly) ? room.poly.map(point).filter(Boolean) : [];
if (poly.length >= 3) return poly;
// Прямоугольная запись старого формата — тоже полигон, просто записанный иначе.
if ([room?.x, room?.y, room?.w, room?.h].every(isFiniteNumber)) {
return [[room.x, room.y], [room.x + room.w, room.y],
[room.x + room.w, room.y + room.h], [room.x, room.y + room.h]];
}
return null;
};
const edgesOf = (poly) => poly.map((a, index) => [a, poly[(index + 1) % poly.length]]);
const distToSegment = (p, a, b) => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (len2 < 1e-18) return Math.hypot(p[0] - a[0], p[1] - a[1]);
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
};
/** Все отрезки пространства, на которых законно жить записи толщины. */
const carriers = (space) => {
const out = [];
for (const room of space?.rooms || []) {
const poly = roomPolygon(room);
if (poly) out.push(...edgesOf(poly));
}
for (const partition of space?.partitions || []) {
const a = point(partition?.a), b = point(partition?.b);
if (a && b) out.push([a, b]);
}
return out;
};
const onSomeCarrier = (span, list, tolerance) => list.some(([a, b]) =>
distToSegment(span[0], a, b) <= tolerance && distToSegment(span[1], a, b) <= tolerance);
/**
* Инвариант 2: каждая ссылка указывает на существующий объект.
*
* Каждое нарушение адресуется: что ссылается, куда и чего не нашли. Пустой
* список — не «наверное всё хорошо», а «проверено».
*/
export function checkReferences({ config, layout = {} } = {}, { notes = [] } = {}) {
const violations = [];
const spaces = Array.isArray(config?.spaces) ? config.spaces : [];
const spaceIds = new Set(spaces.map((space) => String(space?.id ?? '')).filter(Boolean));
const markerIds = new Set((config?.markers || [])
.map((marker) => String(marker?.id ?? '')).filter(Boolean));
const roomIdsBySpace = new Map(spaces.map((space) => [String(space?.id ?? ''),
new Set((space?.rooms || []).map((room) => String(room?.id ?? '')).filter(Boolean))]));
const areasBySpace = new Map(spaces.map((space) => [String(space?.id ?? ''),
new Set((space?.rooms || []).map((room) => String(room?.area ?? '')).filter(Boolean))]));
const add = (kind, owner, reference, detail) =>
violations.push({ invariant: 'references', kind, owner, reference, detail });
for (const marker of config?.markers || []) {
if (marker?.removed) continue;
const space = marker?.space == null ? '' : String(marker.space);
if (space && !spaceIds.has(space)) {
add('marker_space', String(marker.id ?? '?'), space, 'пространства не существует');
}
}
for (const [key, position] of Object.entries(layout || {})) {
const space = position?.s == null ? '' : String(position.s);
if (space && !spaceIds.has(space)) {
add('layout_space', key, space, 'пространства не существует');
continue;
}
// Владелец позиции: подпись комнаты, групповая метка области либо маркер.
if (key.startsWith('rl_')) {
const roomId = key.slice(3);
const rooms = roomIdsBySpace.get(space);
if (rooms && !rooms.has(roomId)) {
add('layout_owner', key, roomId, 'комнаты не существует в этом пространстве');
}
continue;
}
if (key.startsWith('grp_')) {
const area = key.slice(4);
const areas = areasBySpace.get(space);
if (areas && areas.size && !areas.has(area)) {
add('layout_owner', key, area, 'в этом пространстве нет комнаты с такой областью');
}
continue;
}
if (!markerIds.has(key)) {
// Позиция может принадлежать устройству HA, у которого ещё нет записи в
// `markers`: маркеры создаются лениво, а позиция сохраняется сразу. По
// одной конфигурации это не отличить от мусора, поэтому — наблюдение, а
// не нарушение. Проверка с ложными срабатываниями умирает первой.
notes.push({ invariant: 'references', kind: 'unknown_owner', owner: key,
reference: key, detail: 'владелец не найден в конфигурации (возможно устройство HA)' });
}
}
for (const space of spaces) {
const spaceId = String(space?.id ?? '?');
const list = carriers(space);
const tolerance = EDGE_TOLERANCE;
for (const wall of space?.walls || []) {
const a = point(wall?.a), b = point(wall?.b);
if (!a || !b) continue; // запись только с ключом — совместимость, не ссылка
if (!list.length || !onSomeCarrier([a, b], list, tolerance)) {
add('wall_carrier', `${spaceId}:${wall?.key ?? '?'}`, `${wall?.cm} см`,
'запись толщины не лежит ни на одном ребре комнаты и ни на одной перегородке');
}
}
for (const span of space?.open_spans || []) {
const a = point(span?.a), b = point(span?.b);
if (!a || !b) continue;
if (!list.length || !onSomeCarrier([a, b], list, tolerance)) {
add('open_span_carrier', `${spaceId}:${span?.id ?? '?'}`, 'open_span',
'виртуальный проём не лежит на границе существующих комнат');
}
}
}
return violations;
}
/**
* Инвариант 1: запись толщины не исчезает.
*
* Сравнивается мультимножество значений `cm`, а не суммарная длина: ресайз
* законно укорачивает стены, а склейка двух одинаковых записей законно
* уменьшает их число. Незаконно ровно одно — исчезновение значения целиком,
* как в #253, где 33 см пропали вместе с кладкой соседних комнат.
*
* `allowClear` — единственное исключение, и оно объявляется вызывающим:
* пользователь очистил толщину явно.
*/
export function checkWallRecordsPreserved(before, after, { allowClear = false } = {}) {
if (allowClear) return [];
const counts = (walls) => {
const map = new Map();
for (const wall of walls || []) {
if (!isFiniteNumber(wall?.cm) || !(wall.cm > 0)) continue;
const key = String(wall.cm);
map.set(key, (map.get(key) || 0) + 1);
}
return map;
};
const from = counts(before), to = counts(after);
const violations = [];
for (const [cm, was] of from) {
const now = to.get(cm) || 0;
if (now === 0) {
violations.push({
invariant: 'wall_records', kind: 'lost', owner: `${cm} см`,
reference: `было ${was}`, detail: 'записи этой толщины исчезли целиком',
});
}
}
return violations;
}
/** Разобрать любой из трёх форматов, в которых приходит конфигурация. */
export function readModel(text) {
const parsed = JSON.parse(text);
const config = parsed?.payload?.config ?? parsed?.result?.config ?? parsed?.config ?? parsed;
const layout = parsed?.payload?.layout ?? parsed?.result?.layout ?? parsed?.layout ?? {};
return { config, layout };
}
function report(violations, notes = []) {
if (!violations.length) {
const tail = notes.length
? `\nНаблюдений (не нарушения): ${notes.length} — позиции без записи маркера.`
: '';
return 'Инварианты выполнены: неразрешимых ссылок не найдено.' + tail;
}
const lines = [`Нарушений: ${violations.length}.`, ''];
const byKind = new Map();
for (const violation of violations) {
const list = byKind.get(violation.kind) || [];
list.push(violation);
byKind.set(violation.kind, list);
}
const titles = {
marker_space: 'Маркеры ссылаются на несуществующие пространства',
layout_space: 'Позиции ссылаются на несуществующие пространства',
layout_owner: 'Позиции без владельца',
wall_carrier: 'Записи толщины вне рёбер и перегородок',
open_span_carrier: 'Виртуальные проёмы вне границ комнат',
lost: 'Потерянные записи толщины',
};
for (const [kind, list] of byKind) {
lines.push(`${titles[kind] || kind}: ${list.length}`);
for (const violation of list.slice(0, 12)) {
lines.push(` ${violation.owner} → ${violation.reference}: ${violation.detail}`);
}
if (list.length > 12) lines.push(` и ещё ${list.length - 12}`);
lines.push('');
}
return lines.join('\n').trimEnd();
}
function main(argv) {
const arg = (name) => {
const index = argv.indexOf(name);
return index >= 0 ? argv[index + 1] : undefined;
};
const configPath = arg('--config');
if (!configPath) {
console.error('использование: model-invariants.mjs --config <файл> [--layout <файл>] [--json]');
return 2;
}
const model = readModel(readFileSync(configPath, 'utf8'));
if (arg('--layout')) model.layout = readModel(readFileSync(arg('--layout'), 'utf8')).layout;
const notes = [];
const violations = checkReferences(model, { notes });
if (argv.includes('--json')) console.log(JSON.stringify({ violations, notes }, null, 2));
else console.log(report(violations, notes));
// Код возврата — не приговор конфигурации пользователя, а сигнал для CI.
return violations.length ? 1 : 0;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.exit(main(process.argv.slice(2)));
}
+24
View File
@@ -165,6 +165,30 @@ export const MUTANTS = [
replace: ' const span = centre;',
}],
},
{
id: 'invariant-loses-wall-record',
guard: 'node --test --test-name-pattern="исчезнувшая запись толщины" '
+ 'test/model-invariants.test.mjs',
because: 'потеря записи толщины — это дефект #253, найденный человеком глазами; если '
+ 'инвариант перестанет её замечать, класс вернётся в продукт незамеченным (#254)',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' if (now === 0) {',
replace: ' if (false) {',
}],
},
{
id: 'invariant-accepts-dead-space-reference',
guard: 'node --test --test-name-pattern="маркер на удалённое пространство" '
+ 'test/model-invariants.test.mjs',
because: 'маркер, привязанный к удалённому пространству, исчезает с плана молча (#244): '
+ 'проверка ссылок обязана считать это дефектом операции, а не особенностью данных (#254)',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' if (space && !spaceIds.has(space)) {\n add(\'marker_space\'',
replace: ' if (false) {\n add(\'marker_space\'',
}],
},
{
id: 'wall-face-apply-skips-overlap-guard',
guard: 'node demo/smoke_wall_face_overlap.mjs',
+145
View File
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
checkReferences, checkWallRecordsPreserved, readModel,
} from '../scripts/model-invariants.mjs';
// #254. Инварианты нужны не сами по себе, а чтобы ловить уже случившиеся
// дефекты: #253 (запись толщины исчезла при ресайзе), #244 (маркеры на
// удалённые пространства), #252 (позиции без владельца). Поэтому каждый тест
// ниже воспроизводит либо реальный дефект, либо законный случай, который
// проверка обязана НЕ ловить — второе не менее важно: инвариант, кричащий на
// законную операцию, отключат в первую неделю.
const rect = (x0, y0, x1, y1) => [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const model = (overrides = {}) => ({
config: {
spaces: [{
id: 'sp1', cell_cm: 5,
rooms: [{ id: 'r1', name: 'R', area: 'kitchen', poly: rect(0.1, 0.1, 0.4, 0.4) }],
walls: [{ key: 'k1', cm: 30, a: [0.1, 0.1], b: [0.4, 0.1] }],
partitions: [], open_spans: [],
}],
markers: [{ id: 'm1', binding: 'virtual', space: 'sp1' }],
...overrides.config,
},
layout: overrides.layout ?? { m1: { s: 'sp1', x: 0.2, y: 0.2 } },
});
test('чистая модель нарушений не даёт (#254)', () => {
assert.deepEqual(checkReferences(model()), []);
});
test('#244: маркер на удалённое пространство — нарушение', () => {
const m = model();
m.config.markers[0].space = 'f1';
const found = checkReferences(m);
assert.equal(found.length, 1);
assert.equal(found[0].kind, 'marker_space');
assert.equal(found[0].reference, 'f1');
});
test('#244: удалённый маркер не считается нарушением', () => {
// `removed: true` — это надгробие, а не живая ссылка. Ловить его значит
// приучить читателя пролистывать отчёт.
const m = model();
m.config.markers[0].space = 'f1';
m.config.markers[0].removed = true;
assert.deepEqual(checkReferences(m), []);
});
test('#252: позиция на удалённое пространство и позиция без владельца', () => {
const m = model({ layout: {
m1: { s: 'sp1', x: 0.2, y: 0.2 },
ghost: { s: 'space_f1_4c7c573f', x: 0.3, y: 0.3 },
rl_room_gone: { s: 'sp1', x: 0.3, y: 0.3 },
'grp_no_such_area': { s: 'sp1', x: 0.3, y: 0.3 },
} });
const kinds = checkReferences(m).map((v) => `${v.kind}:${v.owner}`);
assert.ok(kinds.includes('layout_space:ghost'));
assert.ok(kinds.includes('layout_owner:rl_room_gone'));
assert.ok(kinds.includes('layout_owner:grp_no_such_area'));
assert.equal(kinds.length, 3, 'живая позиция m1 нарушением быть не должна');
});
test('позиция устройства без записи маркера — наблюдение, а не нарушение (#254)', () => {
// Маркеры создаются лениво, позиция сохраняется сразу: по одной
// конфигурации мусор от живого устройства не отличить. Проверка с ложными
// срабатываниями будет отключена в первую же неделю, поэтому такие случаи
// выводятся отдельным списком.
const m = model({ layout: { '980f1446c4ec1a3a9fa9ff5f6d93caed': { s: 'sp1', x: 0.2, y: 0.2 } } });
const notes = [];
assert.deepEqual(checkReferences(m, { notes }), []);
assert.equal(notes.length, 1);
assert.equal(notes[0].kind, 'unknown_owner');
});
test('фикстуры проекта сами инварианты не нарушают (#254)', async () => {
const { makeVisualMatrixFixture } = await import('../demo/fixtures/visual-matrix.mjs');
const { makeLargeHouseFixture } = await import('../demo/fixtures/large-house.mjs');
for (const make of [makeVisualMatrixFixture, makeLargeHouseFixture]) {
const fixture = make();
const found = checkReferences({ config: fixture.config, layout: fixture.layout || {} });
assert.deepEqual(found.map((v) => `${v.kind}:${v.owner}`), [],
'фикстура, нарушающая инварианты, обесценивает и golden, и смоки на ней');
}
});
test('подпись существующей комнаты и метка существующей области законны', () => {
const m = model({ layout: {
rl_r1: { s: 'sp1', x: 0.2, y: 0.2 },
grp_kitchen: { s: 'sp1', x: 0.25, y: 0.25 },
} });
assert.deepEqual(checkReferences(m), []);
});
test('запись толщины обязана лежать на ребре комнаты или на перегородке', () => {
const m = model();
m.config.spaces[0].walls.push({ key: 'k2', cm: 20, a: [0.8, 0.8], b: [0.9, 0.8] });
const found = checkReferences(m).filter((v) => v.kind === 'wall_carrier');
assert.equal(found.length, 1);
assert.match(found[0].detail, /не лежит/);
// Перегородка — такой же законный носитель, как ребро комнаты.
m.config.spaces[0].partitions.push({ id: 'p1', a: [0.8, 0.8], b: [0.9, 0.8], cm: 20 });
assert.deepEqual(checkReferences(m).filter((v) => v.kind === 'wall_carrier'), []);
});
test('запись только с ключом (совместимость) проверку не роняет', () => {
const m = model();
m.config.spaces[0].walls.push({ key: 'legacy', cm: 15 });
assert.deepEqual(checkReferences(m).filter((v) => v.kind === 'wall_carrier'), []);
});
test('#253: исчезнувшая запись толщины — нарушение первого инварианта', () => {
// Точные числа реального дефекта: 24 записи до жеста, 23 после; исчезла
// горизонтальная стена 33 см, общая с соседними комнатами.
const before = [{ cm: 33 }, { cm: 29 }, { cm: 20 }];
const after = [{ cm: 29 }, { cm: 20 }];
const found = checkWallRecordsPreserved(before, after);
assert.equal(found.length, 1);
assert.equal(found[0].owner, '33 см');
assert.equal(found[0].kind, 'lost');
});
test('законные операции первый инвариант не нарушают (#254)', () => {
// Укорачивание: ресайз сдвинул ребро, записи те же.
assert.deepEqual(checkWallRecordsPreserved([{ cm: 30 }], [{ cm: 30 }]), []);
// Разрез: одна запись стала двумя того же значения.
assert.deepEqual(checkWallRecordsPreserved([{ cm: 30 }], [{ cm: 30 }, { cm: 30 }]), []);
// Склейка: две одинаковые записи стали одной.
assert.deepEqual(checkWallRecordsPreserved([{ cm: 30 }, { cm: 30 }], [{ cm: 30 }]), []);
// Явная очистка толщины пользователем — объявляется вызывающим.
assert.deepEqual(checkWallRecordsPreserved([{ cm: 30 }], [], { allowClear: true }), []);
// Нулевые и мусорные значения записями не считаются.
assert.deepEqual(checkWallRecordsPreserved([{ cm: 0 }, { cm: null }], []), []);
});
test('readModel понимает экспорт, ответ config/get и сырой config (#254)', () => {
const config = { spaces: [], markers: [] };
assert.deepEqual(readModel(JSON.stringify({ payload: { config, layout: { a: 1 } } })),
{ config, layout: { a: 1 } });
assert.deepEqual(readModel(JSON.stringify({ result: { config } })), { config, layout: {} });
assert.deepEqual(readModel(JSON.stringify(config)), { config, layout: {} });
});