mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
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:
File diff suppressed because one or more lines are too long
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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');
|
||||
});
|
||||
@@ -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)`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user