mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
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:
+12
-1
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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="маркер на удалённое пространство" '
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user