import assert from 'node:assert/strict'; import test from 'node:test'; import { readdirSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { checkHiddenObstacles, checkMixedRoleRecords, checkReferences, checkWallKeys, checkWallRecordsPreserved, keyMidpoint, latticeProfile, nearAxisProfile, readModel, wallKey, } from '../scripts/model-invariants.mjs'; import { wallKey as productWallKey } from '../test-build/wall-thickness.js'; import { GRID_STEP_N } from '../test-build/space-geometry.js'; import { canonicalizeConfigGeometry, latticeCanonicalizationReport, } from '../test-build/coordinate-canonicalization.js'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); // #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('#265: полный внутренний ссылочный граф проверяется независимо от импорта', () => { const m = model(); m.config.spaces[0].rooms[0].open_to = ['missing-room']; m.config.spaces[0].openings = [{ id: 'door', type: 'door', x: 0.2, y: 0.1, angle: 0, length: 0.1, host: { kind: 'partition', id: 'missing-partition', t: 0.5 }, }]; Object.assign(m.config.markers[0], { room_id: 'missing-room', vacuum: { segment_map: { 12: 'missing-room' } }, controls: ['marker:missing-light'], value_badge: { enabled: true, source: { kind: 'derived_marker_state', ref: 'marker:missing-source' }, }, }); assert.deepEqual(checkReferences(m).map((item) => item.kind).sort(), [ 'marker_badge', 'marker_control', 'marker_room', 'opening_host', 'room_open_to', 'vacuum_room', ]); }); test('#265: marker-ссылки принимают только активную цель-источник света', () => { const m = model(); m.config.markers.push({ id: 'plain', binding: 'virtual', space: 'sp1' }); m.config.markers[0].controls = ['marker:plain']; m.config.markers[0].value_badge = { enabled: true, source: { kind: 'derived_marker_state', ref: 'marker:plain' }, }; assert.deepEqual(checkReferences(m).map((item) => item.kind).sort(), [ 'marker_badge', 'marker_control', ]); }); test('#252/#566: позиция на удалённое пространство судится по владельцу', () => { // #566 сузил правило. Прежде ЛЮБАЯ позиция на удалённом пространстве была // нарушением, и это расходилось с самим продуктом: `space-reference-repair` // удаляет такую запись только когда может ДОКАЗАТЬ, что владелец тоже // исчез, а иначе хранит её намеренно — удаление уносит расстановку // пользователя. Конфиг сразу после Optimize законно содержит такие записи. const m = model({ layout: { m1: { s: 'sp1', x: 0.2, y: 0.2 }, 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_owner:rl_room_gone')); assert.ok(kinds.includes('layout_owner:grp_no_such_area')); assert.equal(kinds.length, 2, 'живая позиция m1 нарушением быть не должна'); }); test('#566: на удалённом пространстве нарушение — только исчезнувший владелец', () => { const m = model({ 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' }, { id: 'm_gone', binding: 'virtual', space: 'sp1', removed: true }, ], }, layout: { // Владелец жив, исчезло только пространство: продукт хранит намеренно. rl_r1: { s: 'dead_space', x: 0.3, y: 0.3 }, grp_kitchen: { s: 'dead_space', x: 0.3, y: 0.3 }, m1: { s: 'dead_space', x: 0.3, y: 0.3 }, // Владельца по конфигурации не видно: это устройство HA либо мусор, // отличить нельзя — наблюдение, как и внутри живого пространства. '980f1446c4ec1a3a9fa9ff5f6d93caed': { s: 'dead_space', x: 0.3, y: 0.3 }, // Владелец ОТСУТСТВУЕТ по самой конфигурации — это нарушение. rl_room_never_existed: { s: 'dead_space', x: 0.3, y: 0.3 }, grp_area_never_existed: { s: 'dead_space', x: 0.3, y: 0.3 }, m_gone: { s: 'dead_space', x: 0.3, y: 0.3 }, }, }); const notes = []; const found = checkReferences(m, { notes }); assert.deepEqual(found.map((v) => `${v.kind}:${v.owner}`).sort(), [ 'layout_space:grp_area_never_existed', 'layout_space:m_gone', 'layout_space:rl_room_never_existed', ]); // Три состояния, а не два: продукт различает `live` и `unverified`, и // проверка обязана различать их тоже. Сказать «владелец жив» про ключ, // который ни во что не резолвится, — заявить доказанность там, где её нет // (находка r1). Поэтому сверяется и вид наблюдения, и ТЕКСТ причины: вид без // текста эту асимметрию пропускает — именно так она и проехала. assert.deepEqual(notes.filter((n) => n.kind === 'stale_layout_space') .map((n) => n.owner).sort(), ['grp_kitchen', 'm1', 'rl_r1']); assert.deepEqual(notes.filter((n) => n.kind === 'unknown_owner') .map((n) => n.owner), ['980f1446c4ec1a3a9fa9ff5f6d93caed']); for (const note of notes.filter((n) => n.kind === 'stale_layout_space')) { assert.match(note.detail, /владелец жив — Optimize хранит позицию намеренно/); } for (const note of notes.filter((n) => n.kind === 'unknown_owner')) { assert.match(note.detail, /владелец не найден в конфигурации/); assert.doesNotMatch(note.detail, /владелец жив/); } }); 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'); }); // Все модели, которые проект возит с собой, обязаны быть согласованы. Список // не хардкодится: фикстуры перечисляются чтением каталога, поэтому новая // фикстура попадает под проверку сама, без правки этого теста. const fixtureModules = () => readdirSync(resolve(repoRoot, 'demo/fixtures')) .filter((name) => name.endsWith('.mjs')).sort(); const demoStandModel = () => { // Конфигурация стенда живёт внутри demo.html — на ней стоят все 170 смоков. // Если разбор перестанет находить блоки, это само по себе сигнал: стенд // сменил форму, и проверять его надо заново. const html = readFileSync(resolve(repoRoot, 'demo/srv/demo.html'), 'utf8'); const block = (name) => { // `let` с #629: фикстура подменяет конфиг и раскладку, когда смок // доставляет их событием сервера (__pushServerConfig/__pushServerLayout). const declaration = html.search(new RegExp(`\\b(?:const|let) ${name} = `)); assert.ok(declaration >= 0, `в demo.html не найден блок ${name}`); const from = html.indexOf('{', declaration); let depth = 0, index = from; for (; index < html.length; index++) { const char = html[index]; if (char === '{') depth++; else if (char === '}' && --depth === 0) { index++; break; } } // eslint-disable-next-line no-new-func return new Function(`return ${html.slice(from, index)}`)(); }; return { config: block('CFG'), layout: block('LAYOUT') }; }; test('все модели, которые возит с собой проект, инварианты не нарушают (#254)', async () => { const models = []; for (const file of fixtureModules()) { const loaded = await import(`../demo/fixtures/${file}`); for (const [name, value] of Object.entries(loaded)) { if (typeof value !== 'function' || !/^make.*Fixture$/.test(name)) continue; const fixture = value(); if (fixture?.config) models.push([`${file}:${name}`, fixture]); } } models.push(['demo/srv/demo.html', demoStandModel()]); assert.ok(models.length >= 3, `моделей найдено ${models.length}: список подозрительно короток`); for (const [label, fixture] of models) { const found = checkReferences({ config: fixture.config, layout: fixture.layout || {} }); assert.deepEqual(found.map((v) => `${v.kind}:${v.owner} → ${v.reference}`), [], `${label}: модель, нарушающая инварианты, обесценивает и golden, и смоки на ней`); // Долг совместимых ключей на dev виден наблюдениями, а нарушением считается // только объявленная неразрешимой запись с точными концами. Для фикстур // проекта требование строже: ни нарушений, ни наблюдений. Фикстура, чей // ключ не равен ключу своего ребра, держится на запасе и на точных концах, // то есть на удаче, — а на ней стоят golden-эталоны и перф-бюджеты (#260). const keyNotes = []; assert.deepEqual(checkWallKeys(fixture.config, { notes: keyNotes }), [], `${label}: запись толщины объявлена неразрешимой (#258)`); assert.deepEqual(keyNotes.map((n) => n.owner), [], `${label}: ключ записи не равен ключу своего ребра (#260)`); } }); 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: {} }); const space = { id: 'tracked-real-plan', rooms: [] }; assert.deepEqual(readModel(JSON.stringify({ note: 'fixture', space })), { config: { spaces: [space] }, layout: {}, }); }); // --------------------------- инвариант 3: ключи ------------------------------ // #258/#259. После строгого same-span resolver валидные exact endpoints // доказывают, что запись найдётся независимо от старого compatibility key. // Несовпадение остаётся наблюдением для явного Optimize, но не нарушением. const KEYED = (key, cm, a, b) => ({ spaces: [{ id: 'sp1', cell_cm: 5, rooms: [], walls: [{ key, cm, a, b }] }], }); test('#258: старый и неразбираемый compatibility key — наблюдение по exact endpoints', () => { for (const [key, cm, a, b] of [ ['0.887500,0.195833@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333]], ['0.979167,0.445833@1.5706', 28, [0.979166667, 0.345833333], [0.979166667, 0.55]], ['perf-wall-0-3', 15, [0.1, 0.1], [0.5, 0.1]], ['0.300000,0.108333@0.0000', 15, [0.1, 0.1], [0.5, 0.1]], ]) { const notes = []; assert.deepEqual(checkWallKeys(KEYED(key, cm, a, b), { notes }), [], 'exact endpoints make the record resolvable before legacy key fallback'); assert.equal(notes.length, 1); assert.equal(notes[0].kind, 'stale_wall_key'); assert.match(notes[0].detail, /точной паре endpoints/); } }); test('#258: ключ, равный ключу своего ребра, не даёт даже наблюдения', () => { const notes = []; assert.deepEqual(checkWallKeys(KEYED( '0.887500,0.200000@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333], ), { notes }), []); assert.deepEqual(notes, []); }); test('запись без концов ключевую проверку не роняет', () => { // Совместимость: у старых записей есть только ключ. Сверять не с чем, и это // не повод объявлять их сломанными. const notes = []; assert.deepEqual(checkWallKeys({ spaces: [{ id: 'sp1', walls: [{ key: 'legacy', cm: 15 }, { cm: 20 }] }], }, { notes }), []); assert.deepEqual(checkWallKeys({ spaces: [{ id: 'sp1', walls: [ { key: '', cm: 15, a: [0, 0], b: [0.1, 0] }, { key: 42, cm: 15, a: [0, 0], b: [0.1, 0] }, ] }] }, { notes }), []); assert.deepEqual(checkWallKeys(null), []); assert.deepEqual(notes, []); }); test('keyMidpoint читает координаты ключа и отвергает метку (#259)', () => { assert.deepEqual(keyMidpoint('0.887500,0.200000@1.5706'), [0.8875, 0.2]); assert.deepEqual(keyMidpoint('-0.637500,-0.208333@0.0000'), [-0.6375, -0.208333]); for (const bad of ['perf-wall-0-3', 'legacy', '', null, undefined, 'a,b@c', '1,2']) { assert.equal(keyMidpoint(bad), null, `разобрано то, что не должно: ${bad}`); } }); test('копия wallKey в скрипте совпадает с продуктовой (#259)', () => { // Скрипт не импортирует src/**: он читает сырой JSON без сборки. Формулу // приходится дублировать, а дубль величины — тот самый дефект, который проект // ловил трижды. Поэтому копия прикреплена к настоящей здесь. assert.equal(GRID_STEP_N, 1 / 240, 'решётка изменилась — копия ключа устарела'); const nodes = [0, 1, 2, 3, 7, 47, 48, 71, 83, 107, 120, 239, 240]; let checked = 0; for (const i of nodes) { for (const j of nodes) { for (const [dx, dy] of [[0, 1], [1, 0], [1, 1], [1, 2], [3, 1], [0, -1], [-2, 1]]) { const a = [i / 240, j / 240]; const b = [(i + dx) / 240, (j + dy) / 240]; assert.equal(wallKey(a, b), productWallKey(a, b, GRID_STEP_N), `копия разошлась с продуктовой на ${JSON.stringify([a, b])}`); checked++; } } } assert.ok(checked > 500, `сверено ${checked} отрезков: набор подозрительно мал`); // Вырожденный отрезок, ничья округления и координаты не с решётки — там, где // формулы расходятся первыми. for (const [a, b] of [ [[0.5, 0.5], [0.5, 0.5]], [[0.8875, 0.05], [0.8875, 0.345833333]], [[0.979166667, 0.345833333], [0.979166667, 0.55]], [[0.06, 0.08], [0.48, 0.08]], [[-1.670833333, -0.208333333], [0.4, -0.208333333]], [[0, 0], [1 / 240, 0]], ]) { assert.equal(wallKey(a, b), productWallKey(a, b, GRID_STEP_N), `копия разошлась на особом случае ${JSON.stringify([a, b])}`); } }); test('near-grid key normalization removes last-bit grading (#258, #259)', () => { // Four records from the owner's second space differ only in nine-decimal // endpoint representation. Stable wallKey makes all four canonical directly; // neither a violation nor a stale-key observation is left to grade by bits. const walls = [ ['-0.637500,-0.208333@0.0000', 30, [-1.670833333, -0.208333333], [0.4, -0.208333333]], ['-0.637500,1.266667@0.0000', 20, [0.4, 1.266666667], [-1.670833333, 1.266666667]], ['0.354167,2.287500@0.0000', 20, [-0.354166667, 2.2875], [1.058333333, 2.2875]], ['0.354167,3.866667@0.0000', 30, [1.058333333, 3.866666667], [-0.354166667, 3.866666667]], ].map(([key, cm, a, b]) => ({ key, cm, a, b })); const notes = []; assert.deepEqual(checkWallKeys({ spaces: [{ id: 'sp2', cell_cm: 1, walls }] }, { notes }), []); assert.deepEqual(notes, []); }); // ------------------- стадия 0 ADR #282: профиль решётки ---------------------- // Мера, а не приговор. Нарушений здесь не бывает по построению: авторская // координата вне сетки законна, а «шум» — это отдельное население, из которого // растут #258, #279 и несходящийся Optimize. const oneRoom = (poly) => ({ config: { spaces: [{ id: 'sp1', cell_cm: 5, rooms: [{ id: 'r1', poly }] }] }, layout: {}, }); test('профиль различает узел, шум и законную геометрию вне сетки (#282)', () => { // 83/240 выбрано не случайно: это та самая вершина с дачи владельца. В // двоичном виде она 0.34583333333333333, в хранилище лежит как 0.345833333 — // и эти 8e-8 шага перебросили ключ стены в соседний бакет в #258. Узел вида // 24/240 = 0.1 для примера не годится: он выживает округление до девяти // знаков без изменений, и «шума» на нём не получить. const node = 83 / 240; const noise = Number((83 / 240).toFixed(9)); assert.notEqual(node, noise, 'выбранный узел обязан терять точность при записи'); const offGrid = 0.06; // 14.4 шага от узла — авторская координата const profile = latticeProfile(oneRoom([[node, node], [noise, noise], [offGrid, offGrid]])); assert.equal(profile.total, 6); assert.equal(profile.exact, 2, 'узел обязан считаться точным'); assert.equal(profile.noise, 2, '9 знаков от того же узла — это шум, а не узел'); assert.equal(profile.offGrid, 2, '0.06 — законная геометрия, а не дефект'); assert.equal(profile.offGridUnique, 1, 'compatibility projections do not multiply one carrier'); assert.equal(profile.worstNoise.kind, 'room'); assert.ok(profile.worstNoise.steps > 0 && profile.worstNoise.steps < profile.noiseSteps); }); test('профиль видит все виды объектов, а не только комнаты (#282)', () => { const off = 0.0605; const profile = latticeProfile({ config: { spaces: [{ id: 'sp1', rooms: [{ id: 'r1', poly: [[off, off]] }], partitions: [{ id: 'p1', a: [off, off], b: [off, off] }], walls: [{ key: 'k', cm: 20, a: [off, off], b: [off, off] }], open_spans: [{ id: 's1', a: [off, off], b: [off, off] }], wall_columns: [{ id: 'c1', center: [off, off] }], }] }, layout: { m1: { s: 'sp1', x: off, y: off } }, }); assert.deepEqual(Object.keys(profile.byKind).sort(), ['column', 'layout', 'open_span', 'partition', 'room', 'wall']); assert.equal(profile.total, 18); }); test('профиль решётки покрывает полный persisted allow-list (#291)', () => { const off = 0.0605; const profile = latticeProfile({ config: { spaces: [{ id: 'sp1', rooms: [{ id: 'legacy', x: off, y: off, w: off, h: off }], openings: [{ id: 'door', x: off, y: off }], room_drafts: [{ id: 'draft', points: [[off, off]] }], decor: [ { id: 'line', kind: 'line', x1: off, y1: off, x2: off, y2: off }, { id: 'rect', kind: 'rect', x: off, y: off, w: off, h: off }, { id: 'text', kind: 'text', x: off, y: off }, ], }] }, layout: {}, }); assert.deepEqual(Object.keys(profile.byKind).sort(), ['decor', 'opening', 'room', 'room_draft']); assert.equal(profile.total, 18); assert.equal(profile.offGrid, 18); }); test('модели проекта не несут шума решётки (#282)', async () => { // Свойство, которое стоит знать про себя: тестовые данные проекта чисты — // весь их офф-грид авторский. Значит воспроизвести на них #258/#279 нельзя // по построению, а на реальном плане владельца шум составляет две трети // координат. Именно поэтому этот класс дефектов находит владелец, а не гейт. const models = []; for (const file of fixtureModules()) { const loaded = await import(`../demo/fixtures/${file}`); for (const [name, value] of Object.entries(loaded)) { if (typeof value !== 'function' || !/^make.*Fixture$/.test(name)) continue; const fixture = value(); if (fixture?.config) models.push([`${file}:${name}`, fixture]); } } models.push(['demo/srv/demo.html', demoStandModel()]); assert.ok(models.length >= 3); for (const [label, fixture] of models) { const profile = latticeProfile({ config: fixture.config, layout: fixture.layout || {} }); assert.equal(profile.noise, 0, `${label}: в тестовых данных появился шум решётки` + `${profile.worstNoise ? ` — ${profile.worstNoise.owner}` : ''}`); } }); // ------------------ реальные планы как фикстуры (#285, #286) ----------------- // Синтетика не воспроизводит два класса сразу: координатный шум и конфигурации // живого плана. Здесь закреплены свойства, ради которых эти фикстуры и лежат в // репозитории. Если кто-то «почистит» их координаты, фикстуры потеряют смысл — // и тест скажет об этом раньше, чем это выяснится через месяц. const REAL_PLANS = [ { file: 'real-plan-first-floor.json', minNoise: 100, zeroThicknessSolidEdges: 2 }, { file: 'real-plan-second-floor.json', minNoise: 100, zeroThicknessSolidEdges: 0 }, ]; test('реальные планы сохраняют координатный шум, ради которого их взяли (#286)', () => { for (const plan of REAL_PLANS) { const { space } = JSON.parse( readFileSync(resolve(repoRoot, 'test/fixtures', plan.file), 'utf8')); const profile = latticeProfile({ config: { spaces: [space] }, layout: {} }); assert.ok(profile.noise >= plan.minNoise, `${plan.file}: шума ${profile.noise}, ожидалось не меньше ${plan.minNoise} —` + ' фикстура канонизирована и больше не воспроизводит свой класс дефектов'); assert.equal(checkWallKeys({ spaces: [space] }, { notes: [] }).length, 0, `${plan.file}: запись толщины объявлена неразрешимой`); } }); test('барьер очищает клоны реальных планов, не переписывая source fixtures (#291)', () => { for (const plan of REAL_PLANS) { const path = resolve(repoRoot, 'test/fixtures', plan.file); const rawBefore = readFileSync(path, 'utf8'); const { space } = JSON.parse(rawBefore); const source = { spaces: [space], markers: [], settings: {} }; const measured = latticeCanonicalizationReport(source, {}); assert.ok(measured.canonicalized >= plan.minNoise, `${plan.file}: cleanup must be measurable`); const candidate = canonicalizeConfigGeometry(source); const profile = latticeProfile({ config: candidate, layout: {} }); assert.equal(profile.noise, 0, `${plan.file}: boundary must remove all near-node tails`); assert.equal(profile.offGrid, measured.far, `${plan.file}: authored off-grid population survives`); assert.equal(checkWallKeys(candidate, { notes: [] }).length, 0); assert.equal(checkMixedRoleRecords(candidate).length, 0); assert.equal(readFileSync(path, 'utf8'), rawBefore, 'the raw regression fixture is immutable'); } }); test('реальные планы: сплошные рёбра без записи толщины закреплены числом (#286)', async () => { // Ребро, объявленное сплошным, но с нулевой толщиной, рисуется без кладки. // На первом этаже таких два, длиной по 2 шага, и соседние тела их накрывают — // поэтому браузерный смок разрывов не видит. Число закреплено здесь: станет // больше или короче накрытие — увидим до того, как это станет дыркой. const { spaceModels, GRID_STEP_N, GRID_PITCH, NORM_W } = await import('../test-build/space-geometry.js'); const { wallIntervals } = await import('../test-build/wall-thickness.js'); const { resolveOpenCuts } = await import('../test-build/open-spans.js'); for (const plan of REAL_PLANS) { const { space } = JSON.parse( readFileSync(resolve(repoRoot, 'test/fixtures', plan.file), 'utf8')); const model = spaceModels({ spaces: [space], markers: [], settings: {} })[0]; const cuts = resolveOpenCuts( model.rooms, space.open_spans ?? null, NORM_W, GRID_PITCH * 0.02, true); const intervals = wallIntervals( model.rooms, space.walls, cuts, GRID_STEP_N, space.cell_cm || 5, GRID_PITCH, NORM_W); const zero = intervals.filter((item) => item.kind && !(item.cm > 0)); assert.equal(zero.length, plan.zeroThicknessSolidEdges, `${plan.file}: сплошных рёбер без толщины ${zero.length},` + ` ожидалось ${plan.zeroThicknessSolidEdges}`); } }); // ------- инвариант 4: запись толщины не смешивает общее и наружное (#287) ---- // Воспроизведение дефекта владельца из `66.json`: ресайз сдвинул одну комнату // общей пары, вторая осталась, и нижняя часть стены перестала быть общей — // но продолжает нести толщину бывшей общей границы. const pair = (leftPoly, wall) => ({ spaces: [{ id: 'sp', cell_cm: 1, rooms: [ { id: 'left', poly: leftPoly }, { id: 'right', poly: [[1, 0], [2, 0], [2, 1], [1, 1]] }, ], walls: [wall], }], }); test('#287: до ресайза общая стена нарушением не считается', () => { assert.deepEqual(checkMixedRoleRecords(pair( [[0, 0], [1, 0], [1, 1], [0, 1]], { key: 'k', cm: 20, a: [1, 0], b: [1, 1] }, )), []); }); test('#287: после частичного ресайза запись описывает и общее, и наружное', () => { const found = checkMixedRoleRecords(pair( [[0, 0], [1, 0], [1, 1.2], [0, 1.2]], { key: 'k2', cm: 20, a: [1, 0], b: [1, 1.2] }, )); assert.equal(found.length, 1); assert.equal(found[0].kind, 'mixed_role_record'); assert.match(found[0].detail, /частью общий, частью наружный/); assert.match(found[0].detail, /наружного/); }); test('#287: наружный угол одной комнаты — не общая граница', () => { // Считать надо РАЗНЫЕ комнаты, а не рёбра: в углу точка лежит сразу на двух // рёбрах одной комнаты. Подсчёт рёбер давал ложное срабатывание на каждой // наружной стене. assert.deepEqual(checkMixedRoleRecords({ spaces: [{ id: 'sp', cell_cm: 1, rooms: [{ id: 'only', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }], walls: [{ key: 'k', cm: 30, a: [0, 0], b: [1, 0] }], }], }), []); }); test('#287: наружная стена, упирающаяся в общую, нарушением не считается', () => { // Конец записи — это узел, а не участок: там стена законно касается рёбер // двух комнат. Включение концов в выборку давало «95% наружного» на каждой // такой стене, то есть ложное срабатывание на ровном месте. const found = checkMixedRoleRecords({ spaces: [{ id: 'sp', cell_cm: 1, rooms: [ { id: 'left', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }, { id: 'right', poly: [[1, 0], [2, 0], [2, 1], [1, 1]] }, ], // наружная стена левой комнаты, оба конца упираются в общую границу x=1 walls: [{ key: 'outer', cm: 30, a: [0, 0], b: [1, 0] }], }], }); assert.deepEqual(found, []); }); test('#287: реальные планы проекта эту проверку проходят', () => { for (const file of ['real-plan-first-floor.json', 'real-plan-second-floor.json']) { const { space } = JSON.parse( readFileSync(resolve(repoRoot, 'test/fixtures', file), 'utf8')); assert.deepEqual(checkMixedRoleRecords({ spaces: [space] }), [], `${file}: ложное срабатывание на реальном плане`); } }); test('#290 near-axis audit deduplicates shared room-owner copies', () => { const fixture = JSON.parse(readFileSync( resolve(repoRoot, 'test/fixtures/279-near-orthogonal-junction.json'), 'utf8', )); const profile = nearAxisProfile({ spaces: [{ id: 'near', ...fixture }] }); assert.deepEqual(profile, { total: 1, spaces: [{ spaceId: 'near', count: 1 }] }); }); // #296/#297. Геометрия, которая ничего не рисует, но выключает ручки ресайза. // Проверка нужна ровно потому, что все прежние гейтики её не видят: перегородка // под стеной комнаты не портит ни ключи, ни роли, ни решётку, ни кладку. const hidden = (space) => checkHiddenObstacles({ spaces: [{ id: 'sp1', cell_cm: 1, ...space }] }); const roomAt = (x0, y0, x1, y1) => ({ id: 'r1', poly: rect(x0, y0, x1, y1) }); test('перегородка под стеной комнаты — нарушение (#296)', () => { const step = GRID_STEP_N; const found = hidden({ rooms: [roomAt(0, 0, 40 * step, 30 * step)], // Ровно нижнее ребро комнаты: на плане неотличимо от стены. partitions: [{ id: 'p1', a: [0, 0], b: [40 * step, 0], cm: 20 }], }); assert.equal(found.length, 1); assert.equal(found[0].kind, 'partition_over_room_wall'); }); test('перегородка, лежащая на части стены, — тоже нарушение (#296)', () => { const step = GRID_STEP_N; const found = hidden({ rooms: [roomAt(0, 0, 40 * step, 30 * step)], partitions: [{ id: 'p1', a: [10 * step, 0], b: [25 * step, 0], cm: 20 }], }); assert.equal(found.length, 1); assert.equal(found[0].kind, 'partition_over_room_wall'); }); test('перегородка поперёк комнаты законна и не ловится (#296)', () => { // Единственный смысл существования перегородки: разделить комнату внутри. // Инвариант, кричащий на это, отключат в первую неделю. const step = GRID_STEP_N; const found = hidden({ rooms: [roomAt(0, 0, 40 * step, 30 * step)], partitions: [{ id: 'p1', a: [20 * step, 0], b: [20 * step, 30 * step], cm: 20 }], }); assert.deepEqual(found, []); }); test('касание углом перекрытием не считается (#296)', () => { const step = GRID_STEP_N; const found = hidden({ rooms: [roomAt(0, 0, 40 * step, 30 * step)], // Продолжение нижнего ребра за угол: общего с ребром — ровно точка. partitions: [{ id: 'p1', a: [40 * step, 0], b: [60 * step, 0], cm: 20 }], }); assert.deepEqual(found, []); }); test('черновик из двух точек не может стать комнатой (#296)', () => { const step = GRID_STEP_N; const found = hidden({ rooms: [roomAt(0, 0, 40 * step, 30 * step)], room_drafts: [{ id: 'd1', points: [[50 * step, 0], [70 * step, 0]], segments: [{ cm: 30 }], }], }); assert.equal(found.length, 1); assert.equal(found[0].kind, 'unusable_draft'); }); test('черновик из трёх точек в стороне от стен законен (#296)', () => { const step = GRID_STEP_N; const found = hidden({ rooms: [roomAt(0, 0, 40 * step, 30 * step)], room_drafts: [{ id: 'd1', points: [[50 * step, 0], [70 * step, 0], [70 * step, 20 * step]], segments: [{ cm: 30 }, { cm: 30 }], }], }); assert.deepEqual(found, []); }); test('незакрытый контур на стене комнаты — нарушение (#296)', () => { const step = GRID_STEP_N; const found = hidden({ rooms: [roomAt(0, 0, 40 * step, 30 * step)], room_drafts: [{ id: 'd1', points: [[5 * step, 0], [35 * step, 0], [35 * step, 20 * step]], segments: [{ cm: 30 }, { cm: 30 }], }], }); assert.equal(found.length, 1); assert.equal(found[0].kind, 'draft_over_room_wall'); }); test('реальный план: перегородка поверх наружной стены закреплена числом (#296)', () => { // Долг фикстуры, а не «допустимо»: `partition-mt2on9ou-0` лежит на наружной // стене трёх комнат и выключает ресайз Мастера с/у и Гардеробной. Число здесь // обязано стать нулём вместе с закрытием #296 — иначе починка пройдёт молча. const debt = { 'real-plan-second-floor.json': 1, 'real-plan-first-floor.json': 0 }; for (const [file, expected] of Object.entries(debt)) { const { config } = readModel( readFileSync(resolve(repoRoot, 'test/fixtures', file), 'utf8')); assert.equal(checkHiddenObstacles(config).length, expected, file); } }); test('#316: непривязанный контурный проём — валидное состояние v9, но не v8', () => { const base = () => ({ config: { model_version: 9, spaces: [{ id: 'sp316', title: 'S', view_box: [0, 0, 1, 1], rooms: [], openings: [{ id: 'orphan', type: 'door', x: 0.9, y: 0.65, angle: 90, length: 0.05, }], }], markers: [], settings: {}, }, layout: {}, }); const v9 = base(); assert.deepEqual( checkReferences(v9).filter((item) => item.kind === 'opening_host'), [], 'model v9 keeps the unhosted degraded state of #316 §3.3 without a violation', ); const v8 = base(); v8.config.model_version = 8; assert.equal( checkReferences(v8).filter((item) => item.kind === 'opening_host').length, 1, 'model v8 still requires an explicit host on every opening', ); }); test('#316 r4: room_wall_ids проверяется и в v9 — фантомные ссылки ловятся', () => { // CODE-REVIEW-316-r4 H1: правка условия opening_host не имеет права трогать // независимый инвариант room_wall_ids (#244/#252) — он действует для v8 и // всех последующих версий модели. const m = { config: { model_version: 9, spaces: [{ id: 'sp316b', title: 'S', view_box: [0, 0, 1, 1], rooms: [{ id: 'r', name: 'R', area: null, poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.4], [0.1, 0.4]], wall_ids: ['wall-ghost-1', 'wall-ghost-2', 'wall-ghost-3', 'wall-ghost-4'], }], wall_segments: [], }], markers: [], settings: {}, }, layout: {}, }; const kinds = checkReferences(m).filter((item) => item.kind === 'room_wall_ids'); assert.ok(kinds.length >= 1, 'phantom wall_ids must be reported on model v9'); const v8 = structuredClone(m); v8.config.model_version = 8; assert.ok( checkReferences(v8).filter((item) => item.kind === 'room_wall_ids').length >= 1, 'and stay reported on model v8', ); });