diff --git a/package.json b/package.json index 3d6e37de..144a875a 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/model-invariants.mjs b/scripts/model-invariants.mjs new file mode 100644 index 00000000..bd72dd32 --- /dev/null +++ b/scripts/model-invariants.mjs @@ -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))); +} diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index b664e7ea..790a5bfc 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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', diff --git a/test/model-invariants.test.mjs b/test/model-invariants.test.mjs new file mode 100644 index 00000000..1ab3daaf --- /dev/null +++ b/test/model-invariants.test.mjs @@ -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: {} }); +});