From 7b267fb632bc537f54ab3aee27b1d5e45ab217a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 13:50:37 +0300 Subject: [PATCH] test: check the wall key against the lattice edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #258 lost two thickness records to a rounding tie: wallKey quantises the midpoint with Math.round, and a wall of odd step length has its midpoint exactly on the tie, where the exact node 83/240 and the stored 0.345833333 fall on opposite sides. The #254 invariants pass on that file — their edge tolerance is 0.004 and the drift is 0.00417, so the check sits on its own boundary. checkWallKeys compares strings, and against endpoints snapped to the lattice: keying from the raw endpoints flags the healthy state and passes the broken one, which is what the first formulation in #258 got wrong. Measured on the owner's before/after pair: 0 findings before, exactly the two artefact walls after. Two shipped fixtures write keys off the contract (#260); recorded as a number, so the debt can neither grow nor be silently fixed. Issue: #259 User-Visible: no --- docs/WALL-THICKNESS.md | 13 +++- scripts/model-invariants.mjs | 102 +++++++++++++++++++++++++- scripts/mutation-gate.mjs | 25 +++++++ test/model-invariants.test.mjs | 130 ++++++++++++++++++++++++++++++++- 4 files changed, 266 insertions(+), 4 deletions(-) diff --git a/docs/WALL-THICKNESS.md b/docs/WALL-THICKNESS.md index 3f593cc9..a6655437 100644 --- a/docs/WALL-THICKNESS.md +++ b/docs/WALL-THICKNESS.md @@ -21,7 +21,18 @@ carry exact endpoints `a` / `b` in normalised plan coordinates. Config always stores centimetres. Old `{key, cm}` data remains readable and is upgraded when the affected boundary is edited. Open boundaries refuse thickness. One physical stretch has one thickness (atomic collinear spans when neighbours overlap only -partially). Exact endpoints make a thickness boundary independent of whichever +partially). **The key is computed from endpoints snapped to the lattice** +(`Math.round(v · 240) / 240`), never from the raw stored numbers: `wallKey` +quantises the midpoint with `Math.round`, so a wall whose length is an odd +number of grid steps has its midpoint exactly on a rounding tie, and the two +representations of one vertex — the exact node `83/240` and a stored +`0.345833333` — fall on opposite sides of it. That is how #258 lost two records +whose keys had drifted by one step. The tolerant fallback in `lookupWall` does +not rescue such a record: its tolerance is exactly half a pitch, which is +exactly how far a one-step key error sits from the true midpoint, so the +comparison lands on its own boundary. `scripts/model-invariants.mjs` +(`checkWallKeys`, #259) compares the stored key against the lattice key as +strings, without tolerance. Exact endpoints make a thickness boundary independent of whichever room topology later happens to split the same straight line. Normalisation merges consecutive solid pieces into each maximal run of equal thickness; a different thickness or a virtual gap remains a real break. Likewise, diff --git a/scripts/model-invariants.mjs b/scripts/model-invariants.mjs index bd72dd32..987b6df8 100644 --- a/scripts/model-invariants.mjs +++ b/scripts/model-invariants.mjs @@ -22,6 +22,10 @@ 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); @@ -185,6 +189,95 @@ export function checkWallRecordsPreserved(before, after, { allowClear = false } 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)}`; +} + +/** + * Вершина на ближайшем узле решётки. + * + * Через индекс узла, а не делением: `Math.round(v * 240) / 240` даёт точное + * `k/240`, тогда как умножение на шаг накапливает разницу в последних битах — + * а весь этот инвариант живёт именно там. + */ +export const latticePoint = (p) => [ + Math.round(p[0] * GRID_N) / GRID_N, + Math.round(p[1] * GRID_N) / GRID_N, +]; + +/** + * Инвариант 3: ключ записи толщины — это ключ решёточного ребра (#258, #259). + * + * Сравниваются строки, без допусков: допуск здесь и был причиной промаха. + * `checkReferences` проверяет попадание середины на ребро с точностью 0.004, + * а сдвиг ключа на один шаг решётки равен 0.00417 — проверка стояла ровно на + * границе своего же допуска. + * + * Ключ считается от концов, ПРИВЕДЁННЫХ К УЗЛАМ, а не от сырых. Разница не + * косметическая, и первая формулировка в #258 была из-за неё неверной: до + * дефекта ключ `0.887500,0.200000@1.5706` не совпадал с ключом от сырых концов + * записи, и план при этом рисовался верно. Рендер ключует от узловой формы, + * поэтому сверять надо с ней; форма от сырых концов помечает исправное + * состояние и пропускает дефектное — проверено на паре экспортов «до/после». + * + * Причина, по которой одна вершина даёт два разных ключа: `wallKey` квантует + * середину через `Math.round`, а у стены нечётной длины в шагах середина + * попадает ровно на границу округления. `83/240` даёт 47.5 шага и бакет 48, + * записанное в конфиге `0.345833333` — 47.49999996 и бакет 47. + */ +export function checkWallKeys(config) { + const violations = []; + 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(latticePoint(a), latticePoint(b)); + if (wall.key !== expected) { + violations.push({ + invariant: 'wall_keys', kind: 'wall_key', owner: `${spaceId}:${wall.key}`, + reference: expected, + detail: `ключ записи ${wall?.cm} см не равен ключу решёточного ребра —` + + ' запись не найдётся при отрисовке', + }); + } + } + } + return violations; +} + /** Разобрать любой из трёх форматов, в которых приходит конфигурация. */ export function readModel(text) { const parsed = JSON.parse(text); @@ -198,7 +291,8 @@ function report(violations, notes = []) { const tail = notes.length ? `\nНаблюдений (не нарушения): ${notes.length} — позиции без записи маркера.` : ''; - return 'Инварианты выполнены: неразрешимых ссылок не найдено.' + tail; + return 'Инварианты выполнены: ссылки разрешимы, ключи записей толщины на месте.' + + tail; } const lines = [`Нарушений: ${violations.length}.`, '']; const byKind = new Map(); @@ -214,6 +308,7 @@ function report(violations, notes = []) { wall_carrier: 'Записи толщины вне рёбер и перегородок', open_span_carrier: 'Виртуальные проёмы вне границ комнат', lost: 'Потерянные записи толщины', + wall_key: 'Ключи записей толщины не совпадают с ребром решётки', }; for (const [kind, list] of byKind) { lines.push(`${titles[kind] || kind}: ${list.length}`); @@ -239,7 +334,10 @@ function main(argv) { const model = readModel(readFileSync(configPath, 'utf8')); if (arg('--layout')) model.layout = readModel(readFileSync(arg('--layout'), 'utf8')).layout; const notes = []; - const violations = checkReferences(model, { notes }); + const violations = [ + ...checkReferences(model, { notes }), + ...checkWallKeys(model.config), + ]; if (argv.includes('--json')) console.log(JSON.stringify({ violations, notes }, null, 2)); else console.log(report(violations, notes)); // Код возврата — не приговор конфигурации пользователя, а сигнал для CI. diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 4e5dea6d..16b7227b 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -230,6 +230,31 @@ export const MUTANTS = [ replace: ' if (false) {', }], }, + { + id: 'invariant-keys-from-raw-endpoints', + guard: 'node --test --test-name-pattern="ключ, пересчитанный от сырых концов" ' + + 'test/model-invariants.test.mjs', + because: 'ключ считается от концов НА РЕШЁТКЕ, а не от сырых: от сырых проверка ' + + 'помечает исправное состояние и пропускает дефектное — ровно этой ошибкой была ' + + 'первая формулировка в #258', + patches: [{ + file: 'scripts/model-invariants.mjs', + find: ' const expected = wallKey(latticePoint(a), latticePoint(b));', + replace: ' const expected = wallKey(a, b);', + }], + }, + { + id: 'invariant-keys-tolerate-one-step', + guard: 'node --test --test-name-pattern="детектор ключей умеет падать" ' + + 'test/model-invariants.test.mjs', + because: 'смысл проверки — сравнение строк без допусков: допуск здесь и был причиной ' + + 'промаха, потому что сдвиг ключа на шаг решётки равен допуску checkReferences', + patches: [{ + file: 'scripts/model-invariants.mjs', + find: ' if (wall.key !== expected) {', + replace: ' if (false) {', + }], + }, { id: 'invariant-accepts-dead-space-reference', guard: 'node --test --test-name-pattern="маркер на удалённое пространство" ' diff --git a/test/model-invariants.test.mjs b/test/model-invariants.test.mjs index b8d14ff9..a3b63e79 100644 --- a/test/model-invariants.test.mjs +++ b/test/model-invariants.test.mjs @@ -5,8 +5,11 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - checkReferences, checkWallRecordsPreserved, readModel, + checkReferences, checkWallKeys, checkWallRecordsPreserved, latticePoint, + 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'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -107,6 +110,21 @@ const demoStandModel = () => { return { config: block('CFG'), layout: block('LAYOUT') }; }; +/** + * Признанный долг по контракту ключей (#260), а не список исключений. + * + * `large-house` пишет метки вида `perf-wall-0-3`: они не разбираются как + * координаты вовсе, поэтому в перф-профиле путь поиска по ключу мёртв и + * толщину спасают только точные концы. `visual-matrix` пишет ключ с четырьмя + * знаками вместо шести — там расхождение попадает в допуск и рендер не страдает. + * Обе — вход golden и бюджетов, поэтому правка требует переприёмки эталонов: + * решение владельца, а не правка по ходу. + */ +const KEY_CONTRACT_DEBT = new Map([ + ['large-house.mjs:makeLargeHouseFixture', 147], + ['visual-matrix.mjs:makeVisualMatrixFixture', 23], +]); + test('все модели, которые возит с собой проект, инварианты не нарушают (#254)', async () => { const models = []; for (const file of fixtureModules()) { @@ -124,6 +142,20 @@ test('все модели, которые возит с собой проект, const found = checkReferences({ config: fixture.config, layout: fixture.layout || {} }); assert.deepEqual(found.map((v) => `${v.kind}:${v.owner} → ${v.reference}`), [], `${label}: модель, нарушающая инварианты, обесценивает и golden, и смоки на ней`); + + // Ключи (#259) проверяются с одним признанным долгом: две фикстуры пишут + // ключ не по контракту (#260). Долг записан числом, а не исключением по + // имени: вырастет — тест покраснеет, починят — тоже покраснеет и потребует + // убрать запись. Молчаливого исключения здесь быть не должно, иначе + // проверка тихо перестанет что-либо значить. + const debt = KEY_CONTRACT_DEBT.get(label) ?? 0; + const keys = checkWallKeys(fixture.config); + assert.equal(keys.length, debt, debt + ? `${label}: признанный долг #260 — ожидалось ${debt} записей с ключом не по` + + ` контракту, найдено ${keys.length}. Починили — уберите запись из` + + ' KEY_CONTRACT_DEBT; стало больше — фикстура добавила новые.' + : `${label}: ключ записи толщины не равен ключу решёточного ребра —` + + ' при отрисовке такая запись не находится (#258)'); } }); @@ -184,3 +216,99 @@ test('readModel понимает экспорт, ответ config/get и сыр assert.deepEqual(readModel(JSON.stringify({ result: { config } })), { config, layout: {} }); assert.deepEqual(readModel(JSON.stringify(config)), { config, layout: {} }); }); + +// --------------------------- инвариант 3: ключи ------------------------------ +// #258. Числа ниже не придуманы: это две записи из экспорта владельца, на +// которых после «Оптимизировать» появились белые клинья в местах схода стен. +// Стены вертикальные, длиной 71 и 49 шагов решётки — нечётной, поэтому середина +// приходится ровно на границу округления, и бакет решает шум в последних битах. + +const KEYED = (key, cm, a, b) => ({ + config: { spaces: [{ id: 'sp1', cell_cm: 5, rooms: [], walls: [{ key, cm, a, b }] }] }, +}); + +test('#258: ключ, пересчитанный от сырых концов, — нарушение', () => { + const found = checkWallKeys(KEYED( + '0.887500,0.195833@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333], + ).config); + assert.equal(found.length, 1); + assert.equal(found[0].kind, 'wall_key'); + assert.equal(found[0].reference, '0.887500,0.200000@1.5706'); + + const second = checkWallKeys(KEYED( + '0.979167,0.445833@1.5706', 28, [0.979166667, 0.345833333], [0.979166667, 0.55], + ).config); + assert.equal(second.length, 1); + assert.equal(second[0].reference, '0.979167,0.450000@1.5706'); +}); + +test('#258: тот же ключ до дефекта нарушением не считается', () => { + // Обратная половина того же дефекта, и она важнее прямой: форма проверки «от + // сырых концов» помечала бы именно ЭТО состояние, а план на нём рисовался + // верно. Проверка, ругающаяся на исправное, отключается в первую неделю. + assert.deepEqual(checkWallKeys(KEYED( + '0.887500,0.200000@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333], + ).config), []); +}); + +test('запись без концов ключевую проверку не роняет', () => { + // Совместимость: у старых записей есть только ключ. Сверять не с чем, и это + // не повод объявлять их сломанными. + assert.deepEqual(checkWallKeys({ + spaces: [{ id: 'sp1', walls: [{ key: 'legacy', cm: 15 }, { cm: 20 }] }], + }), []); + 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] }, + ] }] }), []); + assert.deepEqual(checkWallKeys(null), []); +}); + +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, 0], [1 / 240, 0]], + ]) { + assert.equal(wallKey(a, b), productWallKey(a, b, GRID_STEP_N), + `копия разошлась на особом случае ${JSON.stringify([a, b])}`); + } +}); + +test('latticePoint даёт точный узел, а не почти узел (#259)', () => { + // Делением на шаг вместо индекса узла 83/240 превращается в значение, которое + // отличается в последних битах — а весь инвариант живёт именно там. + const [, y] = latticePoint([0, 0.345833333]); + assert.equal(y, 83 / 240); + assert.equal(latticePoint([0.05, 0.55])[1], 132 / 240); + assert.deepEqual(latticePoint([-1.670833333, -0.208333333]), [-401 / 240, -50 / 240]); +}); + +test('детектор ключей умеет падать (#259)', () => { + // Проверка на проверку: инвариант, который не может сработать, бесполезен. + const good = KEYED('0.050000,0.050000@0.0000', 20, [0, 0.05], [0.1, 0.05]).config; + assert.deepEqual(checkWallKeys(good), []); + const broken = JSON.parse(JSON.stringify(good)); + broken.spaces[0].walls[0].key = '0.050000,0.054167@0.0000'; + assert.equal(checkWallKeys(broken).length, 1); +});