test(fixtures): write the real wall key in one place
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 32s
Validate / provenance (push) Successful in 42s
Validate / changes (push) Successful in 32s
Validate / process-gate (push) Failing after 41s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / reuse (push) Successful in 46s
Validate / backend (push) Skipped
Validate / frontend (push) Successful in 9m15s
Validate / smoke (1) (push) Failing after 18m47s
Validate / smoke (2) (push) Failing after 19m9s
Validate / smoke (3) (push) Failing after 18m12s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 20m2s
Validate / performance_smoke (push) Failing after 25m25s

Refresh the shared wall-key fixture contract and keep generated frontend bundles synchronized with the fingerprinted geometry fixtures.

Issue: #260
User-Visible: no
This commit is contained in:
Claude
2026-08-24 15:06:06 +03:00
committed by Sergey Matyunin
parent 523190d8f4
commit c14dcecb98
7 changed files with 170 additions and 25 deletions
File diff suppressed because one or more lines are too long
+10 -2
View File
@@ -3,6 +3,8 @@
* visual-regression tooling. Nothing here depends on a real HA installation.
*/
import { fixtureWallKey } from './wall-key.mjs';
const FLOOR_COUNT = 3;
const ROOMS_PER_FLOOR = 20;
const DEVICE_COUNT = 200;
@@ -213,8 +215,14 @@ export const makeLargeHouseFixture = () => {
cell_cm: 5,
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
rooms,
walls: segments.map((wall, index) => ({
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
// The key must be the real one. A label like `perf-wall-0-3` does not
// parse as coordinates, so neither the exact match nor the tolerant
// fallback in lookupWall finds the record: every solid edge of this
// fixture resolved to zero thickness and the plan carried no wall bodies
// at all, while the same fixture backs four golden scenes and all six
// performance budgets (#260).
walls: segments.map((wall) => ({
key: fixtureWallKey(wall.a, wall.b), cm: 15, a: wall.a, b: wall.b,
})),
openings: makeOpenings(floor, segments, openingCount),
partitions: makePartitions(floor, rooms, partitionCount),
+7 -16
View File
@@ -4,22 +4,13 @@ const round = (value) => Number(value.toFixed(6));
// Golden fixtures must use the same persisted wall-key contract as real plan
// data. Arbitrary labels make every configured wall look virtual to the
// renderer, which lets a visually ineffective baseline pass unnoticed.
const WALL_KEY_PITCH = 1 / 240;
export const fixtureWallKey = (a, b) => {
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
const mx = quantize((a[0] + b[0]) / 2);
const my = quantize((a[1] + b[1]) / 2);
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length < 1e-12) { dx = 1; dy = 0; }
else { dx /= length; dy /= length; }
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const bucket = Math.round(angle * 1800) / 1800;
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
};
// renderer, which lets a visually ineffective baseline pass unnoticed — and a
// key that merely differs in precision is found only through the tolerant
// fallback in lookupWall, which is luck rather than contract (#260). The
// formula lives in one place for every fixture and is pinned to the product one
// by test/fixture-wall-key.test.mjs.
export { fixtureWallKey } from './wall-key.mjs';
import { fixtureWallKey } from './wall-key.mjs';
const uniqueEdges = (rooms) => {
const edges = new Map();
+63
View File
@@ -0,0 +1,63 @@
/**
* Ключ записи толщины стены — один на все фикстуры проекта.
*
* Копия формулы из `src/wall-thickness.ts`, и копия здесь неизбежна. Фикстуры
* обязаны оставаться без внешних импортов: бэкенд-гейт запускает их как
* `node --input-type=module --eval "import * as f from './demo/fixtures/…'"`
* в job без `npm ci` и без `test-build/` (`.github/workflows/validate.yml`,
* job `backend`), а `scripts/source-fingerprint.mjs` хеширует только `src/**`
* и `.mjs` из `demo/fixtures` и `demo/golden` — код, втянутый из `scripts/`,
* менял бы поведение фикстуры при неизменном отпечатке, на котором стоят и
* валидность golden-эталонов, и переиспользование гейтов.
*
* Поэтому файл лежит ЗДЕСЬ, внутри `demo/fixtures`: так он попадает в
* отпечаток, и так его видит одна привязка вместо трёх копий формулы.
* `test/fixture-wall-key.test.mjs` сверяет его с продуктовым `wallKey`.
*
* Ловушка, из-за которой этот файл и появился (#260): точность зависит от шага.
* При `pitch = 1/240` продукт печатает ШЕСТЬ знаков, а не четыре — фикстура с
* четырьмя расходилась с продуктом на каждой записи и находилась только через
* терпимый запас `lookupWall`. Метка вместо ключа (`perf-wall-0-3`) не
* находилась вовсе: все сплошные рёбра оставались с нулевой толщиной.
*/
/** Шаг решётки редактора в нормализованных координатах (`GRID_N = 240`). */
export const WALL_KEY_PITCH = 1 / 240;
/** Направление стены по модулю 180°: стена одна и та же с любого конца. */
const direction = (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];
};
/**
* Координата, отличающаяся от узла решётки не больше точности хранения, — это
* тот же узел (#258). Канонизация опознания, а не снап геометрии: произвольная
* точка вне решётки остаётся вне решётки. Без этого шага ничья округления на
* стене нечётной длины в шагах разводила один и тот же ключ на два.
*/
const keyEpsilon = (pitch) => Math.max(Math.abs(pitch) * 1e-6, 1e-9);
const canonical = (value, pitch) => {
if (!(pitch > 0) || !Number.isFinite(value)) return value;
const snapped = Math.round(value / pitch) * pitch;
return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;
};
export const fixtureWallKey = (a, b, pitch = WALL_KEY_PITCH) => {
const quantise = (value) => (pitch > 0 && Number.isFinite(value)
? Math.round(value / pitch) * pitch : value);
const ca = [canonical(a[0], pitch), canonical(a[1], pitch)];
const cb = [canonical(b[0], pitch), canonical(b[1], pitch)];
const mx = quantise((ca[0] + cb[0]) / 2);
const my = quantise((ca[1] + cb[1]) / 2);
const [dx, dy] = direction(ca, cb);
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)}`;
};
+1 -1
View File
File diff suppressed because one or more lines are too long
+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { fixtureWallKey, WALL_KEY_PITCH } from '../demo/fixtures/wall-key.mjs';
import { wallKey as productWallKey } from '../test-build/wall-thickness.js';
import { GRID_STEP_N } from '../test-build/space-geometry.js';
/**
* Привязка ключа фикстур к продуктовому (#260).
*
* Фикстуры обязаны оставаться без внешних импортов: бэкенд-гейт исполняет их
* через `node --eval` в job без `npm ci` и без `test-build/`, а отпечаток
* источников хеширует только `src/**` и `.mjs` из `demo/fixtures`/`demo/golden`.
* Значит формула ключа в `demo/fixtures/wall-key.mjs` — копия, и жить ей
* позволено только под этим тестом.
*
* Цена расхождения измерена, а не предположена. Пока `large-house` писал метки
* `perf-wall-0-3`, продукт не находил ни одной из 147 записей: все 80 сплошных
* рёбер каждого этажа резолвились в нулевую толщину, тел стен не возникало
* вовсе — а на этой фикстуре стоят четыре golden-сцены и все шесть
* перф-бюджетов. `visual-matrix` писала четыре знака вместо шести и держалась
* на терпимом запасе `lookupWall`, то есть на удаче.
*/
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
test('ключ фикстур совпадает с продуктовым на решётке (#260)', () => {
assert.equal(WALL_KEY_PITCH, GRID_STEP_N, 'шаг решётки разошёлся с продуктовым');
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(fixtureWallKey(a, b), productWallKey(a, b, GRID_STEP_N),
`копия разошлась с продуктовой на ${JSON.stringify([a, b])}`);
checked++;
}
}
}
assert.ok(checked > 500, `сверено ${checked} отрезков: набор подозрительно мал`);
});
test('ключ фикстур совпадает и вне решётки (#260)', () => {
// Координаты фикстур авторские (0.06, 0.48, 0.2875) и на узлы не попадают:
// именно там точность и разошлась — шесть знаков против четырёх.
for (const [a, b] of [
[[0.06, 0.08], [0.48, 0.08]],
[[0.48, 0.08], [0.48, 0.48]],
[[0.2875, 0.13], [0.7125, 0.13]],
[[0.5, 0.5], [0.5, 0.5]],
[[-1.670833333, -0.208333333], [0.4, -0.208333333]],
[[0.8875, 0.05], [0.8875, 0.345833333]],
]) {
assert.equal(fixtureWallKey(a, b), productWallKey(a, b, GRID_STEP_N),
`копия разошлась на ${JSON.stringify([a, b])}`);
}
// Шесть знаков, а не четыре: точность зависит от шага, и на 1/240 продукт
// печатает шесть. Ровно эта строка была причиной #260.
assert.equal(fixtureWallKey([0.06, 0.08], [0.48, 0.08]), '0.270833,0.079167@0.0000');
});
test('ни одна фикстура не несёт своей копии ключа (#260)', () => {
// Третья копия формулы — тот же дефект «одно число, два источника», который
// проект уже ловил в #233, #234 и #258. Держим один источник на demo/fixtures.
const dir = `${repoRoot}demo/fixtures`;
const offenders = [];
for (const file of readdirSync(dir).sort()) {
if (!file.endsWith('.mjs') || file === 'wall-key.mjs') continue;
const text = readFileSync(`${dir}/${file}`, 'utf8');
if (/Math\.atan2|toFixed\(4\)\s*\}@|1 \/ 240/.test(text)) offenders.push(file);
}
assert.deepEqual(offenders, [],
'фикстура считает ключ сама — импортируйте fixtureWallKey из wall-key.mjs');
});
+10 -5
View File
@@ -128,11 +128,16 @@ test('все модели, которые возит с собой проект,
assert.deepEqual(found.map((v) => `${v.kind}:${v.owner} → ${v.reference}`), [],
`${label}: модель, нарушающая инварианты, обесценивает и golden, и смоки на ней`);
// Compatibility-key debt remains visible as notes, while exact `a/b`
// proves every shipped record resolvable before midpoint parsing.
const notes = [];
const keys = checkWallKeys(fixture.config, { notes });
assert.deepEqual(keys, [], `${label}: exact wall entry declared unresolvable`);
// Долг совместимых ключей на dev виден наблюдениями, а нарушением считается
// только объявленная неразрешимой запись с точными концами. Для фикстур
// проекта требование строже: ни нарушений, ни наблюдений. Фикстура, чей
// ключ не равен ключу своего ребра, держится на запасе и на точных концах,
// то есть на удаче, — а на ней стоят golden-эталоны и перф-бюджеты (#260).
const keyNotes = [];
assert.deepEqual(checkWallKeys(fixture.config, { notes: keyNotes }), [],
`${label}: запись толщины объявлена неразрешимой (#258)`);
assert.deepEqual(keyNotes.map((n) => n.owner), [],
`${label}: ключ записи не равен ключу своего ребра (#260)`);
}
});