test: check the wall key against the lattice edge

#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
This commit is contained in:
Claude
2026-08-23 13:50:37 +03:00
parent 8c7e8bc9d1
commit 7b267fb632
4 changed files with 266 additions and 4 deletions
+12 -1
View File
@@ -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,
+100 -2
View File
@@ -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.
+25
View File
@@ -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="маркер на удалённое пространство" '
+129 -1
View File
@@ -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);
});