mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
test: grade the wall-key invariant by what the product does
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 1m13s
Validate / provenance (push) Failing after 1m16s
Validate / process-gate (push) Failing after 1m25s
Validate / changes (push) Successful in 1m9s
Validate / reuse (push) Successful in 58s
Validate / hacs (push) Failing after 17s
Validate / hassfest (push) Failing after 20s
Validate / frontend (push) Successful in 10m20s
Validate / backend (push) Failing after 9m54s
Validate / smoke (1) (push) Failing after 18m2s
Validate / smoke (2) (push) Failing after 17m49s
Validate / smoke (3) (push) Failing after 15m44s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 15m1s
Validate / performance_smoke (push) Failing after 13m49s
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 1m13s
Validate / provenance (push) Failing after 1m16s
Validate / process-gate (push) Failing after 1m25s
Validate / changes (push) Successful in 1m9s
Validate / reuse (push) Successful in 58s
Validate / hacs (push) Failing after 17s
Validate / hassfest (push) Failing after 20s
Validate / frontend (push) Successful in 10m20s
Validate / backend (push) Failing after 9m54s
Validate / smoke (1) (push) Failing after 18m2s
Validate / smoke (2) (push) Failing after 17m49s
Validate / smoke (3) (push) Failing after 15m44s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 15m1s
Validate / performance_smoke (push) Failing after 13m49s
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
This commit is contained in:
+14
-12
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
+24
-12
@@ -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) {',
|
||||
}],
|
||||
},
|
||||
|
||||
@@ -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, 'все четыре — один класс, а не два');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user