From 5e95a28406d9cb020fc947cab42b2f6dcb3fcfc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 14:11:22 +0300 Subject: [PATCH] test: grade the wall-key invariant by what the product does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of checkWallKeys compared the stored key against endpoints snapped to the lattice and called any mismatch a violation. Both halves were wrong, and measurement says so: wallIntervals reports the query key for the disputed edge as 0.887500,0.195833@1.5706, i.e. the form built from the coordinates as stored, and the two owner configurations that differ in exactly these keys produce byte-identical wall bodies and multi-wall node maps. The check would have reddened a plan that renders correctly. Graded now: a drift inside the tolerant fallback's half-pitch reach is an observation, a key beyond it or one that does not parse as coordinates is a violation. The threshold is expressed in grid steps with a 1e-3 slack — with a relative 1e-6 the four identically drifted records of one plan split between the two classes on their last bits, so the check repeated the very rounding tie it exists to expose. visual-matrix leaves KEY_CONTRACT_DEBT: its keys drift inside the reach and now read as observations. large-house stays — its labels do not parse, and wallIntervals shows all 80 solid edges resolving to zero thickness (#260). Issue: #259 User-Visible: no --- docs/WALL-THICKNESS.md | 26 +++--- scripts/model-invariants.mjs | 131 ++++++++++++++++++++---------- scripts/mutation-gate.mjs | 36 ++++++--- test/model-invariants.test.mjs | 141 +++++++++++++++++++++------------ 4 files changed, 217 insertions(+), 117 deletions(-) diff --git a/docs/WALL-THICKNESS.md b/docs/WALL-THICKNESS.md index a6655437..6e598080 100644 --- a/docs/WALL-THICKNESS.md +++ b/docs/WALL-THICKNESS.md @@ -21,18 +21,20 @@ 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). **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 +partially). The lookup key is computed from the +edge coordinates **as stored in the configuration**, not from a lattice-snapped +copy of them. That matters because `wallKey` quantises the midpoint with +`Math.round`: a wall whose length is an odd number of grid steps has its +midpoint exactly on a rounding tie, so the exact node `83/240` and a stored +`0.345833333` land in different buckets and the same edge acquires two possible +keys, one grid step apart. A record carrying the other one is still found — the +tolerant fallback in `lookupWall` reaches half a pitch, which is exactly that +distance — but only through the fallback, and the comparison sits on its own +boundary. Measured on two real configurations differing in exactly this way: +identical wall bodies, byte for byte (#258). `scripts/model-invariants.mjs` +(`checkWallKeys`, #259) grades it accordingly — a drift within the fallback's +reach is an observation, a key beyond it or one that does not parse as +coordinates at all is a violation, because nothing will find that record. 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 987b6df8..0cb23f9c 100644 --- a/scripts/model-invariants.mjs +++ b/scripts/model-invariants.mjs @@ -220,59 +220,93 @@ export function wallKey(a, b, pitch = GRID_STEP_N) { 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)}`; +}; + +/** Середина, записанная в ключе. `null` — ключ не разбирается как координаты. */ +export function keyMidpoint(key) { + const match = /^(-?[0-9.]+),(-?[0-9.]+)@(-?[0-9.]+)$/.exec(String(key ?? '')); + if (!match) return null; + const x = Number(match[1]), y = Number(match[2]); + return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null; } /** - * Вершина на ближайшем узле решётки. + * Инвариант 3: ключ записи толщины опознаёт своё же ребро (#258, #259). * - * Через индекс узла, а не делением: `Math.round(v * 240) / 240` даёт точное - * `k/240`, тогда как умножение на шаг накапливает разницу в последних битах — - * а весь этот инвариант живёт именно там. + * Что здесь измерено, а не предположено. Продукт ищет запись по ключу, + * посчитанному от координат ребра **как они лежат в конфигурации** — + * `keyOf(a, b, pitch, coordScale)` в `lookupWall`. Проверено исполнением на + * экспортах владельца: `wallIntervals` даёт для спорного ребра ключ запроса + * `0.887500,0.195833@1.5706`, то есть форму от сырых концов. Первая редакция + * этой проверки сверяла с концами, приведёнными к узлам решётки, и была + * неверна: на решёточную форму продукт не смотрит нигде. + * + * Отсюда две степени, а не одна. `lookupWall` после промаха по строке даёт + * терпимый запас — полшага решётки по середине при совпадении направления: + * + * - расхождение в пределах запаса — НАБЛЮДЕНИЕ. Запись находится, план + * рисуется верно. Проверено: два конфига владельца, различающиеся ровно + * такими ключами, дают побайтово одинаковые тела стен. Объявить это + * нарушением значит покрасить здоровый план — а проверка с ложными + * срабатываниями отключается первой; + * - расхождение больше запаса либо ключ, который вообще не разбирается как + * координаты, — НАРУШЕНИЕ. Такую запись не находит ни точное совпадение, + * ни запас. Ровно это происходит с `demo/fixtures/large-house.mjs` (#260): + * метки вида `perf-wall-0-3` не разбираются, и все 80 сплошных рёбер + * фикстуры остаются с нулевой толщиной — 0 тел стен на плане, который + * служит перф-бюджетом. */ -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) { +export function checkWallKeys(config, { notes = [] } = {}) { const violations = []; + const reach = GRID_STEP_N * 0.5; 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}`, + const expected = wallKey(a, b); + if (wall.key === expected) continue; + const owner = `${spaceId}:${wall.key}`; + const stored = keyMidpoint(wall.key); + if (!stored) { + violations.push({ invariant: 'wall_keys', kind: 'wall_key', owner, reference: expected, - detail: `ключ записи ${wall?.cm} см не равен ключу решёточного ребра —` - + ' запись не найдётся при отрисовке', - }); + detail: `ключ записи ${wall?.cm} см не разбирается как координаты —` + + ' её не найдёт ни точное совпадение, ни терпимый запас' }); + continue; } + const drift = Math.hypot(stored[0] - (a[0] + b[0]) / 2, stored[1] - (a[1] + b[1]) / 2); + // Сравнение с запасом обязано иметь собственный допуск. Сдвиг ключа на + // один шаг решётки даёт середину ровно на полшага, то есть ровно на + // границе запаса — и исход у продукта решает шум в последних битах. + // Измерено: продукт такую запись НАХОДИТ (тела стен двух конфигов + // владельца, различающихся именно этим, побайтово одинаковы). Приговор, + // который решает шум, — не приговор. + // Допуск задан в шагах, а не в долях от `reach`: сдвиг на один шаг даёт + // ровно 0.5, и разные представления одной вершины (точный узел против + // девяти знаков в конфигурации) двигают эту величину на ~5e-5 шага. С + // относительным допуском 1e-6 четыре одинаковых записи одного плана + // делились на «нарушение» и «наблюдение» по последним битам — проверка + // повторяла ту самую болезнь, которую должна показывать. Ближайший + // настоящий класс — 1.5 шага, до него 200%, так что 1e-3 ничего не прячет. + const steps = drift / GRID_STEP_N; + if (steps > 0.5 + 1e-3) { + violations.push({ invariant: 'wall_keys', kind: 'wall_key', owner, + reference: expected, + detail: `середина ключа ушла на ${steps.toFixed(2)} шага —` + + ' дальше терпимого запаса в полшага, запись не найдётся' }); + continue; + } + const edge = Math.abs(steps - 0.5) <= 1e-3 + ? ' и ровно на его границе: у продукта исход решает шум в последних битах' + : ''; + notes.push({ invariant: 'wall_keys', kind: 'stale_wall_key', owner, + reference: expected, + detail: `ключ не равен ключу своего ребра, расхождение ${steps.toFixed(2)}` + + ` шага — запись находится только через терпимый запас${edge}` }); } } return violations; @@ -286,12 +320,23 @@ export function readModel(text) { return { config, layout }; } +/** Наблюдения перечисляются по смыслу: «их 39» читателю ничего не говорит. */ +function noteSummary(notes) { + const counts = new Map(); + for (const note of notes) counts.set(note.kind, (counts.get(note.kind) || 0) + 1); + const titles = { + unknown_owner: 'позиции без записи маркера', + stale_wall_key: 'записей толщины находятся только через терпимый запас', + }; + return [...counts].map(([kind, n]) => `${n} — ${titles[kind] || kind}`).join('; ') + '.'; +} + function report(violations, notes = []) { if (!violations.length) { const tail = notes.length - ? `\nНаблюдений (не нарушения): ${notes.length} — позиции без записи маркера.` + ? `\nНаблюдений (не нарушения): ${notes.length}. ` + noteSummary(notes) : ''; - return 'Инварианты выполнены: ссылки разрешимы, ключи записей толщины на месте.' + return 'Инварианты выполнены: ссылки разрешимы, записи толщины находятся по ключу.' + tail; } const lines = [`Нарушений: ${violations.length}.`, '']; @@ -308,7 +353,7 @@ function report(violations, notes = []) { wall_carrier: 'Записи толщины вне рёбер и перегородок', open_span_carrier: 'Виртуальные проёмы вне границ комнат', lost: 'Потерянные записи толщины', - wall_key: 'Ключи записей толщины не совпадают с ребром решётки', + wall_key: 'Записи толщины, которые не найдутся по ключу', }; for (const [kind, list] of byKind) { lines.push(`${titles[kind] || kind}: ${list.length}`); @@ -336,7 +381,7 @@ function main(argv) { const notes = []; const violations = [ ...checkReferences(model, { notes }), - ...checkWallKeys(model.config), + ...checkWallKeys(model.config, { notes }), ]; if (argv.includes('--json')) console.log(JSON.stringify({ violations, notes }, null, 2)); else console.log(report(violations, notes)); diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 16b7227b..5d9dc1f3 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -231,27 +231,39 @@ export const MUTANTS = [ }], }, { - id: 'invariant-keys-from-raw-endpoints', - guard: 'node --test --test-name-pattern="ключ, пересчитанный от сырых концов" ' + id: 'invariant-keys-cry-wolf', + guard: 'node --test --test-name-pattern="ушедший на шаг решётки" ' + 'test/model-invariants.test.mjs', - because: 'ключ считается от концов НА РЕШЁТКЕ, а не от сырых: от сырых проверка ' - + 'помечает исправное состояние и пропускает дефектное — ровно этой ошибкой была ' - + 'первая формулировка в #258', + because: 'запись, ушедшую на полшага, продукт НАХОДИТ — измерено на двух конфигах ' + + 'владельца с побайтово одинаковыми телами стен; объявить это нарушением значит ' + + 'покрасить исправный план, а такую проверку отключают первой (#258)', patches: [{ file: 'scripts/model-invariants.mjs', - find: ' const expected = wallKey(latticePoint(a), latticePoint(b));', - replace: ' const expected = wallKey(a, b);', + find: ' if (steps > 0.5 + 1e-3) {', + replace: ' if (steps > 0) {', }], }, { - id: 'invariant-keys-tolerate-one-step', - guard: 'node --test --test-name-pattern="детектор ключей умеет падать" ' + id: 'invariant-keys-tolerate-any-drift', + guard: 'node --test --test-name-pattern="дальше терпимого запаса" ' + 'test/model-invariants.test.mjs', - because: 'смысл проверки — сравнение строк без допусков: допуск здесь и был причиной ' - + 'промаха, потому что сдвиг ключа на шаг решётки равен допуску checkReferences', + because: 'ключ, ушедший дальше полшага, не находит ни точное совпадение, ни запас — ' + + 'если проверка это пропустит, запись толщины молча перестанет существовать', patches: [{ file: 'scripts/model-invariants.mjs', - find: ' if (wall.key !== expected) {', + find: ' if (steps > 0.5 + 1e-3) {', + replace: ' if (false) {', + }], + }, + { + id: 'invariant-keys-accept-a-label', + guard: 'node --test --test-name-pattern="не разбирается как координаты" ' + + 'test/model-invariants.test.mjs', + because: 'метка вместо ключа (perf-wall-0-3 в перф-фикстуре, #260) оставляет все 80 ' + + 'сплошных рёбер без толщины: проверка обязана отличать её от сдвига на полшага', + patches: [{ + file: 'scripts/model-invariants.mjs', + find: ' if (!stored) {', replace: ' if (false) {', }], }, diff --git a/test/model-invariants.test.mjs b/test/model-invariants.test.mjs index a3b63e79..37a5e881 100644 --- a/test/model-invariants.test.mjs +++ b/test/model-invariants.test.mjs @@ -5,7 +5,7 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - checkReferences, checkWallKeys, checkWallRecordsPreserved, latticePoint, + checkReferences, checkWallKeys, checkWallRecordsPreserved, keyMidpoint, readModel, wallKey, } from '../scripts/model-invariants.mjs'; import { wallKey as productWallKey } from '../test-build/wall-thickness.js'; @@ -113,16 +113,19 @@ const demoStandModel = () => { /** * Признанный долг по контракту ключей (#260), а не список исключений. * - * `large-house` пишет метки вида `perf-wall-0-3`: они не разбираются как - * координаты вовсе, поэтому в перф-профиле путь поиска по ключу мёртв и - * толщину спасают только точные концы. `visual-matrix` пишет ключ с четырьмя - * знаками вместо шести — там расхождение попадает в допуск и рендер не страдает. - * Обе — вход golden и бюджетов, поэтому правка требует переприёмки эталонов: - * решение владельца, а не правка по ходу. + * `large-house` пишет метки вида `perf-wall-0-3`. Они не разбираются как + * координаты, поэтому их не находит ни точное совпадение, ни терпимый запас: + * измерено продуктовым `wallIntervals` — все 80 сплошных рёбер фикстуры + * остаются с нулевой толщиной и тел стен не возникает вовсе. Это вход и + * golden (4 сцены), и всех шести перф-бюджетов, поэтому правка требует + * переприёмки эталонов — решение владельца, а не правка по ходу. + * + * `visual-matrix` в этот список не входит: её ключи с четырьмя знаками вместо + * шести расходятся в пределах запаса, продукт их находит, и проверка выдаёт по + * ним наблюдения, а не нарушения. */ const KEY_CONTRACT_DEBT = new Map([ ['large-house.mjs:makeLargeHouseFixture', 147], - ['visual-matrix.mjs:makeVisualMatrixFixture', 23], ]); test('все модели, которые возит с собой проект, инварианты не нарушают (#254)', async () => { @@ -149,7 +152,7 @@ test('все модели, которые возит с собой проект, // убрать запись. Молчаливого исключения здесь быть не должно, иначе // проверка тихо перестанет что-либо значить. const debt = KEY_CONTRACT_DEBT.get(label) ?? 0; - const keys = checkWallKeys(fixture.config); + const keys = checkWallKeys(fixture.config, { notes: [] }); assert.equal(keys.length, debt, debt ? `${label}: признанный долг #260 — ожидалось ${debt} записей с ключом не по` + ` контракту, найдено ${keys.length}. Починили — уберите запись из` @@ -218,50 +221,87 @@ test('readModel понимает экспорт, ответ config/get и сыр }); // --------------------------- инвариант 3: ключи ------------------------------ -// #258. Числа ниже не придуманы: это две записи из экспорта владельца, на -// которых после «Оптимизировать» появились белые клинья в местах схода стен. -// Стены вертикальные, длиной 71 и 49 шагов решётки — нечётной, поэтому середина -// приходится ровно на границу округления, и бакет решает шум в последних битах. +// #258/#259. Числа ниже не придуманы: это записи из экспортов владельца. +// +// Главное, что здесь закреплено, — ГРАДАЦИЯ. Первая редакция проверки считала +// сдвиг ключа на шаг решётки нарушением и сверяла ключ с концами, приведёнными +// к узлам. И то и другое неверно: продукт ключует от координат как они лежат в +// конфигурации (проверено `wallIntervals`: для спорного ребра ключ запроса +// `0.887500,0.195833@1.5706`), а запись, ушедшую на полшага, он НАХОДИТ — +// два конфига владельца, различающиеся ровно такими ключами, дают побайтово +// одинаковые тела стен. const KEYED = (key, cm, a, b) => ({ - config: { spaces: [{ id: 'sp1', cell_cm: 5, rooms: [], walls: [{ key, cm, a, b }] }] }, + spaces: [{ id: 'sp1', cell_cm: 5, rooms: [], walls: [{ key, cm, a, b }] }], }); -test('#258: ключ, пересчитанный от сырых концов, — нарушение', () => { - const found = checkWallKeys(KEYED( +test('#258: ключ, ушедший на шаг решётки, — наблюдение, а не нарушение', () => { + for (const [key, cm, a, b] of [ + ['0.887500,0.200000@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333]], + ['0.979167,0.450000@1.5706', 28, [0.979166667, 0.345833333], [0.979166667, 0.55]], + ]) { + const notes = []; + assert.deepEqual(checkWallKeys(KEYED(key, cm, a, b), { notes }), [], + 'проверка, красящая план, который рисуется верно, отключается первой'); + assert.equal(notes.length, 1); + assert.equal(notes[0].kind, 'stale_wall_key'); + // Граница названа прямо: попадание у продукта решает шум в последних битах. + assert.match(notes[0].detail, /ровно на его границе/); + } +}); + +test('#258: ключ, равный ключу своего ребра, не даёт даже наблюдения', () => { + const notes = []; + assert.deepEqual(checkWallKeys(KEYED( '0.887500,0.195833@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333], - ).config); + ), { notes }), []); + assert.deepEqual(notes, []); +}); + +test('#260: ключ, который не разбирается как координаты, — нарушение', () => { + const notes = []; + const found = checkWallKeys( + KEYED('perf-wall-0-3', 15, [0.1, 0.1], [0.5, 0.1]), { notes }, + ); 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'); + assert.equal(found[0].reference, '0.300000,0.100000@0.0000'); + assert.match(found[0].detail, /не разбирается как координаты/); + assert.deepEqual(notes, [], 'нарушение не дублируется наблюдением'); }); -test('#258: тот же ключ до дефекта нарушением не считается', () => { - // Обратная половина того же дефекта, и она важнее прямой: форма проверки «от - // сырых концов» помечала бы именно ЭТО состояние, а план на нём рисовался - // верно. Проверка, ругающаяся на исправное, отключается в первую неделю. - assert.deepEqual(checkWallKeys(KEYED( - '0.887500,0.200000@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333], - ).config), []); +test('ключ, ушедший дальше терпимого запаса, — нарушение', () => { + // Два шага вместо полшага: столько запас не покрывает ни при каком округлении. + const notes = []; + const found = checkWallKeys( + KEYED('0.300000,0.108333@0.0000', 15, [0.1, 0.1], [0.5, 0.1]), { notes }, + ); + assert.equal(found.length, 1); + assert.match(found[0].detail, /2\.00 шага/); + 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)', () => { @@ -283,11 +323,14 @@ test('копия wallKey в скрипте совпадает с продукт } } 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), @@ -295,20 +338,18 @@ test('копия wallKey в скрипте совпадает с продукт } }); -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); +test('градация по шагам не решается последними битами (#259)', () => { + // Четыре записи второго пространства владельца ушли на одни и те же полшага. + // С относительным допуском 1e-6 две из них становились нарушением, две — + // наблюдением: проверка повторяла ту самую ничью округления, которую должна + // показывать. Все четыре обязаны попасть в один класс. + 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.equal(notes.length, 4, 'все четыре — один класс, а не два'); });