mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 1m13s
Validate / provenance (push) Failing after 1m16s
Validate / process-gate (push) Failing after 1m25s
Validate / changes (push) Successful in 1m9s
Validate / reuse (push) Successful in 58s
Validate / hacs (push) Failing after 17s
Validate / hassfest (push) Failing after 20s
Validate / frontend (push) Successful in 10m20s
Validate / backend (push) Failing after 9m54s
Validate / smoke (1) (push) Failing after 18m2s
Validate / smoke (2) (push) Failing after 17m49s
Validate / smoke (3) (push) Failing after 15m44s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 15m1s
Validate / performance_smoke (push) Failing after 13m49s
The first cut of checkWallKeys compared the stored key against endpoints snapped to the lattice and called any mismatch a violation. Both halves were wrong, and measurement says so: wallIntervals reports the query key for the disputed edge as 0.887500,0.195833@1.5706, i.e. the form built from the coordinates as stored, and the two owner configurations that differ in exactly these keys produce byte-identical wall bodies and multi-wall node maps. The check would have reddened a plan that renders correctly. Graded now: a drift inside the tolerant fallback's half-pitch reach is an observation, a key beyond it or one that does not parse as coordinates is a violation. The threshold is expressed in grid steps with a 1e-3 slack — with a relative 1e-6 the four identically drifted records of one plan split between the two classes on their last bits, so the check repeated the very rounding tie it exists to expose. visual-matrix leaves KEY_CONTRACT_DEBT: its keys drift inside the reach and now read as observations. large-house stays — its labels do not parse, and wallIntervals shows all 80 solid edges resolving to zero thickness (#260). Issue: #259 User-Visible: no
395 lines
21 KiB
JavaScript
395 lines
21 KiB
JavaScript
#!/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;
|
|
|
|
/** Решётка редактора: та же, что `GRID_N` в `src/space-geometry.ts`. */
|
|
const GRID_N = 240;
|
|
const GRID_STEP_N = 1 / GRID_N;
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Копия ключа отрезка из `src/wall-thickness.ts`.
|
|
*
|
|
* Дублировать формулу приходится: модуль сознательно читает сырой JSON без
|
|
* сборки, а `wallKey` живёт в TypeScript. Дубль величины, видимой в двух
|
|
* местах, — ровно тот дефект, который проект ловил трижды (#233, #234, #258),
|
|
* поэтому копия прикреплена тестом: `test/model-invariants.test.mjs` берёт
|
|
* настоящий `wallKey` из `test-build` и сверяет с этой копией на наборе
|
|
* отрезков, включая попадающие в ничью округления. Разойдутся — покраснеет.
|
|
*/
|
|
const quantise = (value, pitch) => (!(pitch > 0) || !Number.isFinite(value)
|
|
? value : Math.round(value / pitch) * pitch);
|
|
|
|
const segmentDirection = (a, b) => {
|
|
let dx = b[0] - a[0], dy = b[1] - a[1];
|
|
const length = Math.hypot(dx, dy);
|
|
if (length < 1e-12) return [1, 0];
|
|
dx /= length; dy /= length;
|
|
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) return [-dx, -dy];
|
|
return [dx, dy];
|
|
};
|
|
|
|
export function wallKey(a, b, pitch = GRID_STEP_N) {
|
|
const mx = quantise((a[0] + b[0]) / 2, pitch);
|
|
const my = quantise((a[1] + b[1]) / 2, pitch);
|
|
const [dx, dy] = segmentDirection(a, b);
|
|
let angle = Math.atan2(dy, dx);
|
|
if (angle < 0) angle += Math.PI;
|
|
const bucket = Math.round(angle * 1800) / 1800;
|
|
const precision = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
|
|
return `${mx.toFixed(precision)},${my.toFixed(precision)}@${bucket.toFixed(4)}`;
|
|
};
|
|
|
|
/** Середина, записанная в ключе. `null` — ключ не разбирается как координаты. */
|
|
export function keyMidpoint(key) {
|
|
const match = /^(-?[0-9.]+),(-?[0-9.]+)@(-?[0-9.]+)$/.exec(String(key ?? ''));
|
|
if (!match) return null;
|
|
const x = Number(match[1]), y = Number(match[2]);
|
|
return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null;
|
|
}
|
|
|
|
/**
|
|
* Инвариант 3: ключ записи толщины опознаёт своё же ребро (#258, #259).
|
|
*
|
|
* Что здесь измерено, а не предположено. Продукт ищет запись по ключу,
|
|
* посчитанному от координат ребра **как они лежат в конфигурации** —
|
|
* `keyOf(a, b, pitch, coordScale)` в `lookupWall`. Проверено исполнением на
|
|
* экспортах владельца: `wallIntervals` даёт для спорного ребра ключ запроса
|
|
* `0.887500,0.195833@1.5706`, то есть форму от сырых концов. Первая редакция
|
|
* этой проверки сверяла с концами, приведёнными к узлам решётки, и была
|
|
* неверна: на решёточную форму продукт не смотрит нигде.
|
|
*
|
|
* Отсюда две степени, а не одна. `lookupWall` после промаха по строке даёт
|
|
* терпимый запас — полшага решётки по середине при совпадении направления:
|
|
*
|
|
* - расхождение в пределах запаса — НАБЛЮДЕНИЕ. Запись находится, план
|
|
* рисуется верно. Проверено: два конфига владельца, различающиеся ровно
|
|
* такими ключами, дают побайтово одинаковые тела стен. Объявить это
|
|
* нарушением значит покрасить здоровый план — а проверка с ложными
|
|
* срабатываниями отключается первой;
|
|
* - расхождение больше запаса либо ключ, который вообще не разбирается как
|
|
* координаты, — НАРУШЕНИЕ. Такую запись не находит ни точное совпадение,
|
|
* ни запас. Ровно это происходит с `demo/fixtures/large-house.mjs` (#260):
|
|
* метки вида `perf-wall-0-3` не разбираются, и все 80 сплошных рёбер
|
|
* фикстуры остаются с нулевой толщиной — 0 тел стен на плане, который
|
|
* служит перф-бюджетом.
|
|
*/
|
|
export function checkWallKeys(config, { notes = [] } = {}) {
|
|
const violations = [];
|
|
const reach = GRID_STEP_N * 0.5;
|
|
for (const space of Array.isArray(config?.spaces) ? config.spaces : []) {
|
|
const spaceId = String(space?.id ?? '?');
|
|
for (const wall of space?.walls || []) {
|
|
const a = point(wall?.a), b = point(wall?.b);
|
|
// Запись только с ключом — совместимость: сверять не с чем, и это не
|
|
// повод объявлять её сломанной.
|
|
if (!a || !b) continue;
|
|
if (typeof wall?.key !== 'string' || !wall.key) continue;
|
|
const expected = wallKey(a, b);
|
|
if (wall.key === expected) continue;
|
|
const owner = `${spaceId}:${wall.key}`;
|
|
const stored = keyMidpoint(wall.key);
|
|
if (!stored) {
|
|
violations.push({ invariant: 'wall_keys', kind: 'wall_key', owner,
|
|
reference: expected,
|
|
detail: `ключ записи ${wall?.cm} см не разбирается как координаты —`
|
|
+ ' её не найдёт ни точное совпадение, ни терпимый запас' });
|
|
continue;
|
|
}
|
|
const drift = Math.hypot(stored[0] - (a[0] + b[0]) / 2, stored[1] - (a[1] + b[1]) / 2);
|
|
// Сравнение с запасом обязано иметь собственный допуск. Сдвиг ключа на
|
|
// один шаг решётки даёт середину ровно на полшага, то есть ровно на
|
|
// границе запаса — и исход у продукта решает шум в последних битах.
|
|
// Измерено: продукт такую запись НАХОДИТ (тела стен двух конфигов
|
|
// владельца, различающихся именно этим, побайтово одинаковы). Приговор,
|
|
// который решает шум, — не приговор.
|
|
// Допуск задан в шагах, а не в долях от `reach`: сдвиг на один шаг даёт
|
|
// ровно 0.5, и разные представления одной вершины (точный узел против
|
|
// девяти знаков в конфигурации) двигают эту величину на ~5e-5 шага. С
|
|
// относительным допуском 1e-6 четыре одинаковых записи одного плана
|
|
// делились на «нарушение» и «наблюдение» по последним битам — проверка
|
|
// повторяла ту самую болезнь, которую должна показывать. Ближайший
|
|
// настоящий класс — 1.5 шага, до него 200%, так что 1e-3 ничего не прячет.
|
|
const steps = drift / GRID_STEP_N;
|
|
if (steps > 0.5 + 1e-3) {
|
|
violations.push({ invariant: 'wall_keys', kind: 'wall_key', owner,
|
|
reference: expected,
|
|
detail: `середина ключа ушла на ${steps.toFixed(2)} шага —`
|
|
+ ' дальше терпимого запаса в полшага, запись не найдётся' });
|
|
continue;
|
|
}
|
|
const edge = Math.abs(steps - 0.5) <= 1e-3
|
|
? ' и ровно на его границе: у продукта исход решает шум в последних битах'
|
|
: '';
|
|
notes.push({ invariant: 'wall_keys', kind: 'stale_wall_key', owner,
|
|
reference: expected,
|
|
detail: `ключ не равен ключу своего ребра, расхождение ${steps.toFixed(2)}`
|
|
+ ` шага — запись находится только через терпимый запас${edge}` });
|
|
}
|
|
}
|
|
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 };
|
|
}
|
|
|
|
/** Наблюдения перечисляются по смыслу: «их 39» читателю ничего не говорит. */
|
|
function noteSummary(notes) {
|
|
const counts = new Map();
|
|
for (const note of notes) counts.set(note.kind, (counts.get(note.kind) || 0) + 1);
|
|
const titles = {
|
|
unknown_owner: 'позиции без записи маркера',
|
|
stale_wall_key: 'записей толщины находятся только через терпимый запас',
|
|
};
|
|
return [...counts].map(([kind, n]) => `${n} — ${titles[kind] || kind}`).join('; ') + '.';
|
|
}
|
|
|
|
function report(violations, notes = []) {
|
|
if (!violations.length) {
|
|
const tail = notes.length
|
|
? `\nНаблюдений (не нарушения): ${notes.length}. ` + noteSummary(notes)
|
|
: '';
|
|
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: 'Потерянные записи толщины',
|
|
wall_key: 'Записи толщины, которые не найдутся по ключу',
|
|
};
|
|
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 }),
|
|
...checkWallKeys(model.config, { 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)));
|
|
}
|