From 59be468506521e4452bc7c3fd92c272aaa2e6a60 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sun, 23 Aug 2026 09:40:50 +0300 Subject: [PATCH] test: run model invariants over every shipped model Issue: #254 User-Visible: no --- .github/workflows/process.yml | 10 +++++++ test/model-invariants.test.mjs | 55 +++++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 93bfc1d4..5740f77a 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -359,6 +359,16 @@ jobs: выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev` с красным job `docs` до следующей задачи (#237). + Если diff трогает геометрию или ссылки на неё — рёбра комнат, + записи толщины, `layout`, `marker.space`, `open_spans` — обязательны + инварианты модели (#254): `npm test` уже гоняет их на всех моделях + проекта, а на конкретной конфигурации они проверяются командой + `npm run invariants -- --config <экспорт или ответ config/get>`. + Два вопроса, на которые они отвечают, и оба уже стоили продукту + дефектов: не исчезла ли запись толщины (#253) и разрешима ли каждая + ссылка (#244, #252). Если задача меняет геометрию, а инварианты в + отчёте не названы — это непрогнанный гейт, а не мелочь. + По необходимости, и «необходимость» определяется diff'ом и AC: - браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те, что печатает `node scripts/smoke-select.mjs --base --head `. diff --git a/test/model-invariants.test.mjs b/test/model-invariants.test.mjs index 1ab3daaf..b8d14ff9 100644 --- a/test/model-invariants.test.mjs +++ b/test/model-invariants.test.mjs @@ -1,10 +1,15 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { checkReferences, checkWallRecordsPreserved, readModel, } from '../scripts/model-invariants.mjs'; +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); + // #254. Инварианты нужны не сами по себе, а чтобы ловить уже случившиеся // дефекты: #253 (запись толщины исчезла при ресайзе), #244 (маркеры на // удалённые пространства), #252 (позиции без владельца). Поэтому каждый тест @@ -75,14 +80,50 @@ test('позиция устройства без записи маркера — assert.equal(notes[0].kind, 'unknown_owner'); }); -test('фикстуры проекта сами инварианты не нарушают (#254)', async () => { - const { makeVisualMatrixFixture } = await import('../demo/fixtures/visual-matrix.mjs'); - const { makeLargeHouseFixture } = await import('../demo/fixtures/large-house.mjs'); - for (const make of [makeVisualMatrixFixture, makeLargeHouseFixture]) { - const fixture = make(); +// Все модели, которые проект возит с собой, обязаны быть согласованы. Список +// не хардкодится: фикстуры перечисляются чтением каталога, поэтому новая +// фикстура попадает под проверку сама, без правки этого теста. +const fixtureModules = () => readdirSync(resolve(repoRoot, 'demo/fixtures')) + .filter((name) => name.endsWith('.mjs')).sort(); + +const demoStandModel = () => { + // Конфигурация стенда живёт внутри demo.html — на ней стоят все 170 смоков. + // Если разбор перестанет находить блоки, это само по себе сигнал: стенд + // сменил форму, и проверять его надо заново. + const html = readFileSync(resolve(repoRoot, 'demo/srv/demo.html'), 'utf8'); + const block = (name) => { + const declaration = html.indexOf(`const ${name} = `); + assert.ok(declaration >= 0, `в demo.html не найден блок ${name}`); + const from = html.indexOf('{', declaration); + let depth = 0, index = from; + for (; index < html.length; index++) { + const char = html[index]; + if (char === '{') depth++; + else if (char === '}' && --depth === 0) { index++; break; } + } + // eslint-disable-next-line no-new-func + return new Function(`return ${html.slice(from, index)}`)(); + }; + return { config: block('CFG'), layout: block('LAYOUT') }; +}; + +test('все модели, которые возит с собой проект, инварианты не нарушают (#254)', async () => { + const models = []; + for (const file of fixtureModules()) { + const loaded = await import(`../demo/fixtures/${file}`); + for (const [name, value] of Object.entries(loaded)) { + if (typeof value !== 'function' || !/^make.*Fixture$/.test(name)) continue; + const fixture = value(); + if (fixture?.config) models.push([`${file}:${name}`, fixture]); + } + } + models.push(['demo/srv/demo.html', demoStandModel()]); + assert.ok(models.length >= 3, `моделей найдено ${models.length}: список подозрительно короток`); + + for (const [label, fixture] of models) { const found = checkReferences({ config: fixture.config, layout: fixture.layout || {} }); - assert.deepEqual(found.map((v) => `${v.kind}:${v.owner}`), [], - 'фикстура, нарушающая инварианты, обесценивает и golden, и смоки на ней'); + assert.deepEqual(found.map((v) => `${v.kind}:${v.owner} → ${v.reference}`), [], + `${label}: модель, нарушающая инварианты, обесценивает и golden, и смоки на ней`); } });