From 76d8017e87fbde452964c657ffd6ed6b453bf604 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 13 Sep 2026 09:19:00 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20TS/Python=20parity=20=D0=BD=D0=B0=20=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=BE=D0=BC=20runner=20(#548)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #548 User-Visible: no --- .github/workflows/validate.yml | 59 +++++++- AGENTS.md | 6 +- PROCESS.md | 3 + docs/ARCHITECTURE.md | 7 + docs/DEVELOPMENT.md | 5 + scripts/check-inputs.mjs | 11 ++ scripts/ci-proof.mjs | 4 + scripts/classify-changes.mjs | 1 + scripts/gate-reuse.mjs | 8 +- scripts/mutation-gate.mjs | 13 ++ test/check-inputs.test.mjs | 16 +- test/ci-proof.test.mjs | 10 +- test/classify-changes.test.mjs | 14 ++ test/fixtures/junction-limits-parity.json | 115 ++++++++++++++ test/gate-reuse.test.mjs | 14 +- test/validate-workflow.test.mjs | 38 ++++- tests_backend/junction_parity.py | 176 ++++++++++++++++++++++ tests_backend/test_junction_limits.py | 79 +--------- tests_backend/test_junction_parity.py | 20 +++ tsconfig.junction-parity.json | 13 ++ 20 files changed, 520 insertions(+), 92 deletions(-) create mode 100644 test/fixtures/junction-limits-parity.json create mode 100644 tests_backend/junction_parity.py create mode 100644 tests_backend/test_junction_parity.py create mode 100644 tsconfig.junction-parity.json diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 895ae938..cea55e6a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -216,6 +216,7 @@ jobs: actions: read outputs: frontend: ${{ steps.classify.outputs.frontend }} + geometry_parity: ${{ steps.classify.outputs.geometry_parity }} backend: ${{ steps.classify.outputs.backend }} integration: ${{ steps.classify.outputs.integration }} perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5 @@ -370,11 +371,13 @@ jobs: smoke: ${{ steps.probe.outputs.smoke }} golden: ${{ steps.probe.outputs.golden }} performance_smoke: ${{ steps.probe.outputs.performance_smoke }} + geometry_parity: ${{ steps.probe.outputs.geometry_parity }} backend: ${{ steps.probe.outputs.backend }} smoke_key: ${{ steps.keys.outputs.smoke }} golden_key: ${{ steps.keys.outputs.golden }} performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }} performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }} + geometry_parity_key: ${{ steps.keys.outputs.geometry_parity }} backend_key: ${{ steps.keys.outputs.backend }} smoke_source_run: ${{ steps.p_smoke.outputs.source_run }} smoke_source_attempt: ${{ steps.p_smoke.outputs.source_attempt }} @@ -385,6 +388,9 @@ jobs: performance_smoke_source_run: ${{ steps.p_perf.outputs.source_run }} performance_smoke_source_attempt: ${{ steps.p_perf.outputs.source_attempt }} performance_smoke_source_sha: ${{ steps.p_perf.outputs.source_sha }} + geometry_parity_source_run: ${{ steps.p_geometry_parity.outputs.source_run }} + geometry_parity_source_attempt: ${{ steps.p_geometry_parity.outputs.source_attempt }} + geometry_parity_source_sha: ${{ steps.p_geometry_parity.outputs.source_sha }} backend_source_run: ${{ steps.p_backend.outputs.source_run }} backend_source_attempt: ${{ steps.p_backend.outputs.source_attempt }} backend_source_sha: ${{ steps.p_backend.outputs.source_sha }} @@ -398,7 +404,7 @@ jobs: PERF_ISO: ${{ needs.changes.outputs.perf_iso }} PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }} run: | - for job in smoke golden performance_smoke backend; do + for job in smoke golden performance_smoke geometry_parity backend; do key=$(node scripts/gate-reuse.mjs --job="$job") echo "$job=$key" >> "$GITHUB_OUTPUT" echo "$job: $key" @@ -451,6 +457,19 @@ jobs: - name: Очистить маркер performance_smoke перед следующим lookup if: always() run: rm -f .reuse-marker + - name: Маркер geometry_parity + id: m_geometry_parity + uses: actions/cache/restore@v6 + with: + path: .reuse-marker + key: reuse-geometry_parity-${{ steps.keys.outputs.geometry_parity }} + - name: Доказательство источника geometry_parity + id: p_geometry_parity + if: steps.m_geometry_parity.outputs.cache-hit == 'true' + run: node scripts/ci-proof.mjs --marker=.reuse-marker + - name: Очистить маркер geometry_parity перед следующим lookup + if: always() + run: rm -f .reuse-marker - name: Маркер backend id: m_backend uses: actions/cache/restore@v6 @@ -470,6 +489,7 @@ jobs: SMOKE: ${{ steps.m_smoke.outputs.cache-hit }} GOLDEN: ${{ steps.m_golden.outputs.cache-hit }} PERF: ${{ steps.m_perf.outputs.cache-hit }} + GEOMETRY_PARITY: ${{ steps.m_geometry_parity.outputs.cache-hit }} BACKEND: ${{ steps.m_backend.outputs.cache-hit }} run: | # Пропуск обязан быть громким: молчаливый skip — тот самый тихий @@ -488,6 +508,7 @@ jobs: waive smoke "$SMOKE" waive golden "$GOLDEN" waive performance_smoke "$PERF" + waive geometry_parity "$GEOMETRY_PARITY" waive backend "$BACKEND" hacs: @@ -1048,6 +1069,40 @@ jobs: path: .reuse-marker key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }} + geometry_parity: + name: "Геометрия: TS/Python parity исполнена" + needs: [changes, reuse] + if: needs.changes.outputs.geometry_parity == 'true' && needs.reuse.outputs.geometry_parity != 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - uses: actions/setup-python@v7 + with: + python-version-file: .python-version + - run: npm ci + - name: Компилировать узкое TS-зеркало П1–П4 + run: | + npx --no-install tsc -p tsconfig.junction-parity.json + node scripts/fix-test-build.mjs + - name: Сравнить настоящие TS/Python validators (fail closed) + run: python tests_backend/junction_parity.py --build-dir=test-build/junction-parity + - name: Записать маркер успеха + run: | + printf '%s\n' "geometry_parity прогнана успешно" \ + "SHA: ${{ github.sha }}" \ + "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + "попытка: ${{ github.run_attempt }}" \ + > .reuse-marker + - uses: actions/cache/save@v6 + continue-on-error: true + with: + path: .reuse-marker + key: reuse-geometry_parity-${{ needs.reuse.outputs.geometry_parity_key }} + backend: name: "Бэкенд: pytest в Home Assistant" needs: [changes, reuse] @@ -1137,7 +1192,7 @@ jobs: proof: name: "Доказательство выполненных проверок" if: always() - needs: [preflight, changes, reuse, hacs, hassfest, changed_mutants, frontend, smoke, smoke_done, golden, performance_smoke, backend] + needs: [preflight, changes, reuse, hacs, hassfest, changed_mutants, frontend, smoke, smoke_done, golden, performance_smoke, geometry_parity, backend] runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 diff --git a/AGENTS.md b/AGENTS.md index 8ef4bb69..fbffd09c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -390,7 +390,8 @@ push (#479).** `smoke`, `golden` and `performance_smoke` in Validate are gated on the `heavy` output: true for a head commit carrying a `Release:` trailer, for `workflow_dispatch full=true` (which `nightly.yml` issues on `dev` every night) and for pull requests. A plain push to `dev` runs preflight, frontend (types, -units, build, bundle sync, no-new-any), backend, hacs and hassfest. Screenshot +units, build, bundle sync, no-new-any), the narrow TS/Python geometry parity +guard when its inputs changed, backend, hacs and hassfest. Screenshot freshness in `check-docs` is likewise a warning on a plain push and an error on the candidate; `publish-prerelease.yml` and `release.yml` refuse a candidate without the `Release:` trailer, so a green Validate without the heavy jobs can @@ -478,7 +479,8 @@ assertions go red and part stay green, which reads as a logic defect. success` run for the candidate's SHA, not "the last green one"; a new push cancels an unfinished Validate for the same branch. Gate jobs, matching the actual `validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`, -`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job +`frontend`, `smoke`, `golden`, `performance_smoke`, `geometry_parity`, `backend`. +The `changes` job is a service path-filter, not a gate. `docs` is a real blocker: it checks the screenshots `sourceFingerprint` against current `src/**`, which is exactly what went red after the #113 merge. A stable release additionally waits for Full diff --git a/PROCESS.md b/PROCESS.md index 634ad2dc..da7026cd 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -633,6 +633,9 @@ npm run golden:verify # если менялся визуал node scripts/check-docs.mjs # если менялся src/** node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:check), если менялся бэкенд +npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \ + && python tests_backend/junction_parity.py --build-dir=test-build/junction-parity + # если менялось одно из зеркал junction limits ``` **Новый код не добавляет `any`** (#342). В `src/**` уже 1034 вхождения явного diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9b43ec5a..c730ebc4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2034,6 +2034,13 @@ not the feeling that "editor text should be lazy". - The backend CI job measures branch coverage (pure + HA harness combined), fails below `scripts/backend-coverage-baseline.txt` and refuses to run when the HA harness would silently skip. +- The junction-limit mirror has a separate clean-runner `geometry_parity` job + (#548). It compiles only the transitive TypeScript graph rooted at + `src/junction-limits.ts`, loads the production Python module without Home + Assistant and compares both over `test/fixtures/junction-limits-parity.json`. + The job fails closed on missing build/runtime prerequisites, announces the + number of executed scenarios, and reuses a result only when both mirrors, + fixture, toolchain pins and harness inputs are byte-identical. - `const.ERROR_CODES` / `ERROR_CODE_FAMILIES` are THE stable error contract: the scanner test proves every emitted code (send_error literals, exception class attrs, literal and variable-passed MarkerControlError codes, f-string diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 37febb1a..95584ce8 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -130,6 +130,11 @@ git config core.untrackedCache true `$env:PYTEST_DISABLE_PLUGIN_AUTOLOAD='1'`. - Full backend (including `test_ha_*.py`): `python -m pytest tests_backend/ -q` in CI or WSL/Linux only. +- Junction-limit TS/Python parity (clean, no Home Assistant): + `npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs && + python tests_backend/junction_parity.py --build-dir=test-build/junction-parity`. + Its dedicated reusable Validate job owns this proof; missing compiled modules + fail setup instead of turning into a pytest skip. - IMPORTANT (audit lesson): the rollup typescript plugin reports a syntax error as a WARNING and still builds the bundle — a truncated file can "pass". That is why the build starts with `tsc --noEmit`, which fails on such errors. Always build with `npm run build`, never bare `rollup -c`. diff --git a/scripts/check-inputs.mjs b/scripts/check-inputs.mjs index 63e680d6..d0736a2b 100755 --- a/scripts/check-inputs.mjs +++ b/scripts/check-inputs.mjs @@ -324,6 +324,17 @@ export const CHECKS = { roots: [...BUILD_INPUTS, 'demo/performance/**', ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW], reuse: true, }, + geometry_parity: { + // #548: узкий clean-runner guard реальных TS/Python зеркал П1–П4. + // Полный frontend artifact и HA harness ему не нужны; каждый фактический + // вход перечислен здесь и участвует одновременно в selection и reuse key. + entries: ['tests_backend/junction_parity.py', 'src/junction-limits.ts', + 'custom_components/houseplan/junction_limits.py', 'scripts/fix-test-build.mjs'], + roots: ['tests_backend/pure_imports.py', 'custom_components/houseplan/wall_segment_model.py', + 'test/fixtures/junction-limits-parity.json', 'tsconfig.junction-parity.json', 'tsconfig.json', + 'package.json', 'package-lock.json', '.nvmrc', '.python-version', ...REUSE_PROTOCOL, ...WORKFLOW], + reuse: true, + }, backend: { // pytest tests_backend, unittest scripts/support-relay/tests, ruff/mypy, порог покрытия entries: ['tests_backend/**/*.py', 'scripts/support-relay/tests/**/*.py', 'scripts/dump-config-schema.py'], diff --git a/scripts/ci-proof.mjs b/scripts/ci-proof.mjs index 951d850e..9a08ad1b 100644 --- a/scripts/ci-proof.mjs +++ b/scripts/ci-proof.mjs @@ -36,6 +36,7 @@ const JOB_RULES = Object.freeze({ ], golden: [{ exact: 'Golden-кадры против принятых эталонов', count: 1 }], performance_smoke: [{ exact: 'Перф-смок: бюджет времени кадра', count: 1 }], + geometry_parity: [{ exact: 'Геометрия: TS/Python parity исполнена', count: 1 }], backend: [{ exact: 'Бэкенд: pytest в Home Assistant', count: 1 }], }); @@ -66,6 +67,7 @@ export function requiredCheckIds({ request = {}, selection = {} } = {}) { if (asBool(selection.integration)) ids.push('integration'); if (asBool(request.mutants)) ids.push('mutants'); if (asBool(request.full)) ids.push('smoke', 'golden', 'performance_smoke'); + if (asBool(selection.geometry_parity)) ids.push('geometry_parity'); if (asBool(selection.backend)) ids.push('backend'); return ids; } @@ -90,6 +92,7 @@ export function buildCiProof({ }; const selection = { frontend: asBool(changes.frontend), + geometry_parity: asBool(changes.geometry_parity), backend: asBool(changes.backend), integration: asBool(changes.integration), }; @@ -124,6 +127,7 @@ export function buildCiProof({ executedOrReused('golden'); executedOrReused('performance_smoke'); } + if (selection.geometry_parity) executedOrReused('geometry_parity'); if (selection.backend) executedOrReused('backend'); const requiredChecks = requiredCheckIds({ request, selection }); return { diff --git a/scripts/classify-changes.mjs b/scripts/classify-changes.mjs index a6a18446..e4c19c4e 100755 --- a/scripts/classify-changes.mjs +++ b/scripts/classify-changes.mjs @@ -27,6 +27,7 @@ import { checksAffectedBy } from './check-inputs.mjs'; */ export const CHECK_OF_OUTPUT = { frontend: 'frontend', + geometry_parity: 'geometry_parity', backend: 'backend', integration: 'integration', mutants: 'changed_mutants', diff --git a/scripts/gate-reuse.mjs b/scripts/gate-reuse.mjs index 56651ad1..b196d504 100644 --- a/scripts/gate-reuse.mjs +++ b/scripts/gate-reuse.mjs @@ -1,6 +1,7 @@ // Ключ переиспользования результата тяжёлой job (issue #208). // -// Тяжёлые job Validate — smoke, golden, performance_smoke, backend — прогонялись +// Reusable job Validate — smoke, golden, performance_smoke, geometry_parity, +// backend — прогонялись // на каждый push в dev, включая коммиты в документацию, workflows и процессные // скрипты, где бандл и оснастка побайтово те же. Ключ здесь отвечает на один // вопрос: «менялось ли хоть что-то, от чего результат этой job зависит». @@ -17,8 +18,9 @@ // пишет только успешный прогон с тем же ключом. // // Свойство, которое стоит знать: релизный кандидат (бета или стабильный релиз) -// бампает версию — `package.json`/`CARD_VERSION` у браузерных job и -// `custom_components/houseplan/manifest.json` у backend входят в manifest. +// бампает версию — `package.json`/`CARD_VERSION` у браузерных job, +// `package.json` у geometry_parity и `custom_components/houseplan/manifest.json` +// у backend входят в manifest. // Значит ключ кандидата заведомо новый и полный набор гейтов прогоняется всегда. // Переиспользование физически не может ослабить релизный гейт. diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 82411ae4..16e98239 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -3202,6 +3202,19 @@ const MUTANT_DEFINITIONS = [ + '`${point[0].toFixed(6)},${point[1].toFixed(6)}`;', }], }, + { + id: 'junction-limit-ts-python-parity-drift', + guard: 'npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs ' + + '&& python3 tests_backend/junction_parity.py --build-dir=test-build/junction-parity', + because: 'the clean-runner parity job must turn red when either production mirror changes ' + + 'one shared rule; otherwise a green run can again mean that no cross-runtime comparison ' + + 'actually happened (#548 AC2)', + patches: [{ + file: 'src/junction-limits.ts', + find: 'export const MIN_NODE_DISTANCE_CM = 5;', + replace: 'export const MIN_NODE_DISTANCE_CM = 4; // mutant: drift from Python mirror', + }], + }, { id: 'junction-limit-branch-dropped', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' diff --git a/test/check-inputs.test.mjs b/test/check-inputs.test.mjs index d371b07e..f21f9495 100755 --- a/test/check-inputs.test.mjs +++ b/test/check-inputs.test.mjs @@ -122,7 +122,7 @@ test('замыкание останавливается на копиях бан }); test('каждая проверка объявлена, у тяжёлых job включён реюз, у остальных нет', () => { - assert.deepEqual(REUSE_JOBS, ['smoke', 'golden', 'performance_smoke', 'backend']); + assert.deepEqual(REUSE_JOBS, ['smoke', 'golden', 'performance_smoke', 'geometry_parity', 'backend']); for (const name of CHECK_NAMES) { assert.ok(MANIFEST[name].size > 0, `${name}: пустой manifest`); assert.ok(Array.isArray(CHECKS[name].entries) && Array.isArray(CHECKS[name].roots), name); @@ -132,6 +132,16 @@ test('каждая проверка объявлена, у тяжёлых job в test('§8.1 представители: каждая категория каждой тяжёлой job — её вход', () => { const expect = { + geometry_parity: { + source: ['src/junction-limits.ts', 'src/space-geometry.ts', + 'custom_components/houseplan/junction_limits.py', 'custom_components/houseplan/wall_segment_model.py'], + tests: ['tests_backend/junction_parity.py'], + fixtures: ['test/fixtures/junction-limits-parity.json'], + config: ['tsconfig.junction-parity.json', 'tsconfig.json'], + toolchain: ['package.json', 'package-lock.json', '.nvmrc', '.python-version', + '.github/workflows/validate.yml'], + protocol: ['scripts/fix-test-build.mjs', 'scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs'], + }, backend: { source: ['custom_components/houseplan/websocket_api.py', 'scripts/support-relay/relay.py', 'scripts/support-relay/hp_relay/app.py', 'scripts/sh3d-convert/convert.mjs', 'scripts/dump-config-schema.py'], @@ -216,6 +226,10 @@ test('§8.1 обратная проба (AC6): UI — не вход backend, bac for (const job of ['smoke', 'golden', 'performance_smoke']) { assert.ok(![...MANIFEST[job]].some((f) => f.startsWith('tests_backend/')), `${job} читает tests_backend`); } + for (const file of ['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts', + 'custom_components/houseplan/websocket_api.py']) { + assert.ok(!MANIFEST.geometry_parity.has(file), `geometry_parity зависит от ${file}`); + } }); test('§5.5 лист покрытия: ни одного неизвестного исполняемого файла, ни одной лишней записи NOT_AN_INPUT', () => { diff --git a/test/ci-proof.test.mjs b/test/ci-proof.test.mjs index 0f49e236..ae319993 100644 --- a/test/ci-proof.test.mjs +++ b/test/ci-proof.test.mjs @@ -20,6 +20,7 @@ const names = { smokeDone: 'Смоки: все шарды зелёные', golden: 'Golden-кадры против принятых эталонов', performance: 'Перф-смок: бюджет времени кадра', + geometryParity: 'Геометрия: TS/Python parity исполнена', backend: 'Бэкенд: pytest в Home Assistant', }; @@ -33,7 +34,7 @@ const smokeJobs = () => Array.from({ length: 3 }, (_, index) => ( export function proofFixture({ id = 10, attempt = 2, full = true, mutants = true, - frontend = true, backend = true, integration = true, conclusion = 'success', + frontend = true, geometryParity = true, backend = true, integration = true, conclusion = 'success', } = {}) { const needs = { preflight: { result: 'success' }, @@ -41,7 +42,8 @@ export function proofFixture({ result: 'success', outputs: { heavy: String(full), mutants_requested: String(mutants), - frontend: String(frontend), backend: String(backend), integration: String(integration), + frontend: String(frontend), geometry_parity: String(geometryParity), + backend: String(backend), integration: String(integration), }, }, reuse: { result: 'success', outputs: {} }, @@ -53,6 +55,7 @@ export function proofFixture({ smoke_done: { result: full ? 'success' : 'skipped' }, golden: { result: full ? 'success' : 'skipped' }, performance_smoke: { result: full ? 'success' : 'skipped' }, + geometry_parity: { result: geometryParity ? 'success' : 'skipped' }, backend: { result: backend ? 'success' : 'skipped' }, }; const proof = buildCiProof({ @@ -64,6 +67,7 @@ export function proofFixture({ if (integration) jobs.push(success(names.hacs), success(names.hassfest)); if (mutants) jobs.push(...mutantJobs()); if (full) jobs.push(...smokeJobs(), success(names.smokeDone), success(names.golden), success(names.performance)); + if (geometryParity) jobs.push(success(names.geometryParity)); if (backend) jobs.push(success(names.backend)); const run = { databaseId: id, attempt, status: 'completed', conclusion, @@ -80,7 +84,7 @@ test('#541: proof records candidate identity, request and exact required check s }); assert.deepEqual(fixture.proof.requiredChecks, [ 'preflight', 'changes', 'reuse', 'frontend', 'integration', 'mutants', - 'smoke', 'golden', 'performance_smoke', 'backend', + 'smoke', 'golden', 'performance_smoke', 'geometry_parity', 'backend', ]); assert.deepEqual(fixture.proof.requiredChecks, requiredCheckIds(fixture.proof)); }); diff --git a/test/classify-changes.test.mjs b/test/classify-changes.test.mjs index 63e4b932..48dc910d 100644 --- a/test/classify-changes.test.mjs +++ b/test/classify-changes.test.mjs @@ -68,6 +68,20 @@ test('#492 §1.2: relay, converter, schema и pyproject запускают backe } }); +test('#548: parity выбирается по обоим зеркалам, fixture и toolchain, но не по чужому UI', () => { + for (const file of ['src/junction-limits.ts', 'src/space-geometry.ts', + 'custom_components/houseplan/junction_limits.py', + 'custom_components/houseplan/wall_segment_model.py', + 'tests_backend/junction_parity.py', 'test/fixtures/junction-limits-parity.json', + 'tsconfig.junction-parity.json', 'package-lock.json', '.nvmrc', '.python-version']) { + const out = classify([file]); + assert.equal(out.geometry_parity, 'true', file); + assert.deepEqual(out.unknown, [], file); + } + assert.equal(classify(['src/houseplan-card.ts']).geometry_parity, 'false'); + assert.equal(classify(['custom_components/houseplan/websocket_api.py']).geometry_parity, 'false'); +}); + test('#492 AC6: правка UI не классифицируется как backend', () => { const out = classify(['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts']); assert.equal(out.backend, 'false'); diff --git a/test/fixtures/junction-limits-parity.json b/test/fixtures/junction-limits-parity.json new file mode 100644 index 00000000..15eae4fc --- /dev/null +++ b/test/fixtures/junction-limits-parity.json @@ -0,0 +1,115 @@ +{ + "schema_version": 1, + "cell_cm": 5, + "cases": [ + { + "name": "angle-14", + "segments": [ + { "ray_degrees": 0, "length_cm": 100, "thickness_cm": 15 }, + { "ray_degrees": 14, "length_cm": 100, "thickness_cm": 15 } + ] + }, + { + "name": "angle-15", + "segments": [ + { "ray_degrees": 0, "length_cm": 100, "thickness_cm": 15 }, + { "ray_degrees": 15, "length_cm": 100, "thickness_cm": 15 } + ] + }, + { + "name": "valence-7", + "segments": [ + { "ray_degrees": 0, "length_cm": 100, "thickness_cm": 15 }, + { "ray_degrees": 51, "length_cm": 100, "thickness_cm": 15 }, + { "ray_degrees": 102, "length_cm": 100, "thickness_cm": 15 }, + { "ray_degrees": 153, "length_cm": 100, "thickness_cm": 15 }, + { "ray_degrees": 204, "length_cm": 100, "thickness_cm": 15 }, + { "ray_degrees": 255, "length_cm": 100, "thickness_cm": 15 }, + { "ray_degrees": 306, "length_cm": 100, "thickness_cm": 15 } + ] + }, + { + "name": "length-19", + "segments": [ + { "a": { "cm": [0, 0] }, "b": { "cm": [19, 0] }, "thickness_cm": 15 } + ] + }, + { + "name": "length-run", + "segments": [ + { "a": { "cm": [0, 0] }, "b": { "cm": [10, 0] }, "thickness_cm": 15 }, + { "a": { "cm": [10, 0] }, "b": { "cm": [20, 0] }, "thickness_cm": 15 } + ] + }, + { + "name": "length-thickness", + "segments": [ + { "a": { "cm": [0, 0] }, "b": { "cm": [25, 0] }, "thickness_cm": 30 } + ] + }, + { + "name": "distance-4", + "segments": [ + { "a": { "cm": [0, 0] }, "b": { "cm": [300, 0] }, "thickness_cm": 15 }, + { "a": { "cm": [0, 4] }, "b": { "cm": [300, 4] }, "thickness_cm": 15 } + ] + }, + { + "name": "tee", + "segments": [ + { "a": { "cm": [0, 0] }, "b": { "cm": [300, 0] }, "thickness_cm": 15 }, + { "a": { "cm": [150, 0] }, "b": { "cm": [150, 300] }, "thickness_cm": 15 } + ] + }, + { + "name": "angle-15-exact", + "segments": [ + { "ray_degrees": 0, "length_cm": 100, "thickness_cm": 15 }, + { "ray_degrees": 15, "length_cm": 100, "thickness_cm": 15 } + ] + }, + { + "name": "length-20-exact", + "segments": [ + { "a": { "cm": [0, 0] }, "b": { "cm": [20, 0] }, "thickness_cm": 15 } + ] + }, + { + "name": "filler-run", + "segments": [ + { "a": { "cm": [0, 0] }, "b": { "cm": [349, 0] }, "thickness_cm": 30 }, + { "a": { "cm": [349, 0] }, "b": { "cm": [354, 0] }, "thickness_cm": 30 }, + { "a": { "cm": [354, 0] }, "b": { "cm": [554, 0] }, "thickness_cm": 20 } + ] + }, + { + "name": "distance-5-exact", + "segments": [ + { "a": { "cm": [0, 0] }, "b": { "cm": [300, 0] }, "thickness_cm": 15 }, + { "a": { "cm": [0, 5] }, "b": { "cm": [300, 5] }, "thickness_cm": 15 } + ] + }, + { + "name": "debris-node", + "segments": [ + { "a": { "units": [-1e-8, 0] }, "b": { "cm": [300, 0] }, "thickness_cm": 15 }, + { "a": { "cm": [0, 0] }, "b": { "cm": [0, 300] }, "thickness_cm": 15 } + ] + }, + { + "name": "duplicate-wall", + "segments": [ + { "a": { "cm": [0, 0] }, "b": { "cm": [300, 0] }, "thickness_cm": 15 }, + { "a": { "cm": [0, 0] }, "b": { "cm": [300, 0] }, "thickness_cm": 15 } + ] + }, + { + "name": "collinear-fork", + "segments": [ + { "a": { "cm": [0, 0] }, "b": { "cm": [100, 0] }, "thickness_cm": 15 }, + { "a": { "cm": [100, 0] }, "b": { "cm": [160, 0] }, "thickness_cm": 15 }, + { "a": { "cm": [100, 0] }, "b": { "cm": [220, 0], "units_offset": [0, 1e-9] }, "thickness_cm": 15 } + ] + } + ] +} diff --git a/test/gate-reuse.test.mjs b/test/gate-reuse.test.mjs index adea307d..2808cbaf 100644 --- a/test/gate-reuse.test.mjs +++ b/test/gate-reuse.test.mjs @@ -27,13 +27,19 @@ const makeTree = () => { put('package-lock.json', '{"lockfileVersion":3}\n'); put('rollup.config.mjs', 'export default {};\n'); put('tsconfig.json', '{}\n'); + put('tsconfig.junction-parity.json', '{}\n'); + put('.nvmrc', '22\n'); + put('.python-version', '3.14\n'); put('.github/workflows/validate.yml', 'name: Validate\n'); put('scripts/source-fingerprint.mjs', '// pinned by the real repo copy\n'); put('scripts/gate-reuse.mjs', '// reuse protocol\n'); put('scripts/check-inputs.mjs', '// manifest\n'); + put('scripts/fix-test-build.mjs', '// esm import fixer\n'); put('src/card.ts', "export const CARD_VERSION = '1.0.0';\n"); put('src/plan-optimizer.ts', 'export const PLAN_MODEL_VERSION = 1;\n'); put('src/logic.ts', 'export const logic = 1;\n'); + put('src/junction-limits.ts', "import './space-geometry';\nexport const MIN = 5;\n"); + put('src/space-geometry.ts', 'export const GRID_STEP_N = 1 / 240;\n'); put('demo/serve.mjs', "import './bundle-freshness.mjs';\n"); put('demo/bundle-freshness.mjs', 'export const fresh = 1;\n'); put('demo/srv/demo.html', '
\n'); @@ -51,7 +57,12 @@ const makeTree = () => { put('demo/performance/compare.mjs', 'export const cmp = 1;\n'); put('demo/performance/budgets-glow-smoke.json', '{"hardMaxMs":1}\n'); put('tests_backend/test_pure.py', 'from custom_components.houseplan.store import VERSION\n\ndef test_x():\n assert True\n'); + put('tests_backend/junction_parity.py', 'from pure_imports import load_pure\n'); + put('tests_backend/pure_imports.py', 'def load_pure():\n pass\n'); put('tests_backend/requirements.txt', 'pytest\n'); + put('test/fixtures/junction-limits-parity.json', '{"schema_version":1}\n'); + put('custom_components/houseplan/junction_limits.py', 'from .wall_segment_model import VERSION\n'); + put('custom_components/houseplan/wall_segment_model.py', 'VERSION = 1\n'); put('custom_components/houseplan/store.py', 'VERSION = 1\n'); put('custom_components/houseplan/manifest.json', '{"domain":"houseplan","version":"1.0.0"}\n'); put('custom_components/houseplan/frontend/houseplan-card.js', 'built bundle\n'); @@ -71,7 +82,7 @@ const keys = (dir) => Object.fromEntries(JOBS.map((job) => [job, reuseKey(dir, j test('every heavy job has non-empty inputs and its own key', () => { const { dir } = makeTree(); try { - assert.deepEqual(JOBS, ['smoke', 'golden', 'performance_smoke', 'backend']); + assert.deepEqual(JOBS, ['smoke', 'golden', 'performance_smoke', 'geometry_parity', 'backend']); const k = keys(dir); for (const job of JOBS) assert.ok(harnessFiles(dir, job).length > 0, job); assert.equal(new Set(Object.values(k)).size, JOBS.length, 'ключи job обязаны различаться'); @@ -152,6 +163,7 @@ test('harness edits are isolated to their own job (#208)', () => { only(['golden'])(() => put('demo/golden/baselines/one.png', Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x02]))); only(['performance_smoke'])(() => put('demo/performance/compare.mjs', 'export const cmp = 2;\n')); + only(['geometry_parity'])(() => put('test/fixtures/junction-limits-parity.json', '{"schema_version":1,"changed":true}\n')); only(['backend'])(() => put('custom_components/houseplan/store.py', 'VERSION = 2\n')); // Протокол браузерного харнеса общий для трёх job (#492 §5.1 protocol). only(['smoke', 'golden', 'performance_smoke'])(() => put('demo/serve.mjs', "import './bundle-freshness.mjs';\n// harness\n")); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 8a017e63..bff693cd 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -91,7 +91,7 @@ test('джобы с полной историей качают её без бл // job `changes`, поэтому имя job ищется только с начала строки. const start = workflow.indexOf(`\n ${job}:\n`); assert.ok(start > 0, `нет job ${job}`); - const chunk = workflow.slice(start, start + 1400); + const chunk = workflow.slice(start, start + 1800); assert.match(chunk, /fetch-depth: 0, filter: 'blob:none'/, `${job} обязана качать историю без блобов`); } @@ -193,7 +193,7 @@ test('перф-смок добавляет профиль ровно при св assert.ok(!changes.includes("printf 'frontend=true"), 'ручной список выходов в fallback-е запрещён'); const start = workflow.indexOf('\n performance_smoke:\n'); - const job = workflow.slice(start, workflow.indexOf('\n backend:\n', start)); + const job = workflow.slice(start, workflow.indexOf('\n geometry_parity:\n', start)); assert.match(job, /needs: \[changes, frontend, reuse\]/); const iso = job.slice(job.indexOf('Изометрический профиль по диффу'), job.indexOf('Профиль взаимодействия по диффу')); assert.match(iso, /if: needs\.changes\.outputs\.perf_iso == 'true'/); @@ -208,6 +208,34 @@ test('перф-смок добавляет профиль ровно при св assert.ok(!/\n\s+if:/.test(glow), 'glow-профили гоняются всегда'); }); +test('#548: TS/Python parity — отдельная clean-runner job без тихого skip', () => { + const workflow = read('validate.yml'); + const start = workflow.indexOf('\n geometry_parity:\n'); + const job = workflow.slice(start, workflow.indexOf('\n backend:\n', start)); + assert.ok(start > 0, 'нет job geometry_parity'); + assert.match(job, /needs: \[changes, reuse\]/); + assert.match(job, /if: needs\.changes\.outputs\.geometry_parity == 'true'/); + assert.match(job, /node-version-file: \.nvmrc/); + assert.match(job, /python-version-file: \.python-version/); + assert.match(job, /npx --no-install tsc -p tsconfig\.junction-parity\.json/); + assert.match(job, /python tests_backend\/junction_parity\.py --build-dir=test-build\/junction-parity/); + assert.match(job, /reuse-geometry_parity-\$\{\{ needs\.reuse\.outputs\.geometry_parity_key \}\}/); + assert.ok(!/pytest\.skip|continue-on-error:[^\n]*\n[^]*junction_parity\.py/.test(job), + 'паритет не может становиться зелёным через skip/continue-on-error'); + + const changes = workflow.slice(workflow.indexOf('\n changes:\n'), workflow.indexOf('\n reuse:\n')); + assert.match(changes, /geometry_parity: \$\{\{ steps\.classify\.outputs\.geometry_parity \}\}/); + const reuse = workflow.slice(workflow.indexOf('\n reuse:\n'), workflow.indexOf('\n hacs:\n')); + assert.match(reuse, /geometry_parity_key: \$\{\{ steps\.keys\.outputs\.geometry_parity \}\}/); + assert.match(reuse, /waive geometry_parity "\$GEOMETRY_PARITY"/); + + const oldPytestHome = readFileSync( + new URL('../tests_backend/test_junction_limits.py', import.meta.url), 'utf8', + ); + assert.doesNotMatch(oldPytestHome, /pytest\.skip\([^\n]*test-build/, + 'старый optional parity pytest не должен возвращаться рядом с обязательной job'); +}); + test('ключ reuse перф-смока различает наборы профилей (#473 AC5)', () => { const workflow = read('validate.yml'); const reuse = workflow.slice(workflow.indexOf('\n reuse:\n'), workflow.indexOf('\n hacs:\n')); @@ -468,7 +496,7 @@ test('#541: Validate всегда публикует proof точной попы assert.match(proof, /if: always\(\)/, 'proof создаётся и на красном прогоне'); for (const dependency of [ 'preflight', 'changes', 'reuse', 'hacs', 'hassfest', 'changed_mutants', - 'frontend', 'smoke', 'smoke_done', 'golden', 'performance_smoke', 'backend', + 'frontend', 'smoke', 'smoke_done', 'golden', 'performance_smoke', 'geometry_parity', 'backend', ]) assert.match(proof, new RegExp(`needs: \\[[^\\n]*\\b${dependency}\\b`), dependency); assert.match(proof, /CANDIDATE_SHA: \$\{\{ github\.sha \}\}/); assert.match(proof, /CANDIDATE_TREE: \$\{\{ steps\.candidate\.outputs\.tree \}\}/); @@ -480,12 +508,12 @@ test('#541: Validate всегда публикует proof точной попы assert.match(proof, /name: ci-proof-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/); const reuse = workflow.slice(workflow.indexOf('\n reuse:\n'), workflow.indexOf('\n hacs:\n')); - for (const id of ['smoke', 'golden', 'performance_smoke', 'backend']) { + for (const id of ['smoke', 'golden', 'performance_smoke', 'geometry_parity', 'backend']) { assert.match(reuse, new RegExp(`${id}_source_run:`), `${id}: source run output`); assert.match(reuse, new RegExp(`${id}_source_attempt:`), `${id}: source attempt output`); assert.match(reuse, new RegExp(`${id}_source_sha:`), `${id}: source SHA output`); } - assert.equal((reuse.match(/node scripts\/ci-proof\.mjs --marker=\.reuse-marker/g) || []).length, 4); + assert.equal((reuse.match(/node scripts\/ci-proof\.mjs --marker=\.reuse-marker/g) || []).length, 5); assert.equal(reuse.includes('lookup-only: true'), false, 'marker contents must be restored and verified, not reduced to a cache-hit bit'); }); diff --git a/tests_backend/junction_parity.py b/tests_backend/junction_parity.py new file mode 100644 index 00000000..5eda5e06 --- /dev/null +++ b/tests_backend/junction_parity.py @@ -0,0 +1,176 @@ +"""Fail-closed TypeScript/Python parity harness for wall-junction limits (#548). + +This module deliberately has no pytest or Home Assistant dependency. The +dedicated Validate job compiles the real TypeScript module, loads the real pure +Python validator and compares both against one checked-in fixture. Missing +runtime/build prerequisites are setup errors, never a skipped test. +""" +from __future__ import annotations + +import argparse +import json +import math +import os +import subprocess +from pathlib import Path +from typing import Any + +from pure_imports import HOUSEPLAN_ROOT, REPO, load_pure + +DEFAULT_FIXTURE = REPO / "test" / "fixtures" / "junction-limits-parity.json" +DEFAULT_BUILD_DIR = REPO / "test-build" / "junction-parity" + +_NODE_SCRIPT = r""" +const junction = await import(process.env.HP_JUNCTION_MODULE); +const geometry = await import(process.env.HP_GEOMETRY_MODULE); +let raw = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { raw += chunk; }); +process.stdin.on('end', () => { + const spaces = JSON.parse(raw); + const out = {}; + for (const [name, space] of Object.entries(spaces)) { + const segments = space.wall_segments; + const violations = [ + ...junction.checkNodes(segments), + ...junction.checkSegmentLengths(segments, space.cell_cm, geometry.GRID_STEP_N), + ...junction.checkNodeDistances(segments, space.cell_cm, geometry.GRID_STEP_N), + ]; + out[name] = [...new Set(violations.map((item) => item.rule))].sort(); + } + process.stdout.write(JSON.stringify(out)); +}); +""" + + +def load_backend_validator(): + """Load the production Python mirror without importing Home Assistant.""" + return load_pure( + "custom_components.houseplan.junction_limits", + HOUSEPLAN_ROOT / "junction_limits.py", + ) + + +def _point(value: dict[str, Any], *, cell_cm: float, validator: Any) -> list[float]: + if "cm" in value and set(value).issubset({"cm", "units_offset"}): + point = value["cm"] + offset = value.get("units_offset", [0, 0]) + return [validator.cm_to_units(float(point[0]), cell_cm) + float(offset[0]), + validator.cm_to_units(float(point[1]), cell_cm) + float(offset[1])] + if set(value) == {"units"}: + point = value["units"] + return [float(point[0]), float(point[1])] + raise ValueError(f"point must contain cm (optionally units_offset) or exactly units, got: {value!r}") + + +def load_spaces(fixture_path: Path = DEFAULT_FIXTURE, *, validator: Any | None = None) -> dict[str, Any]: + """Expand the human-sized shared fixture into the production space shape.""" + backend = validator or load_backend_validator() + data = json.loads(fixture_path.read_text(encoding="utf-8")) + if data.get("schema_version") != 1: + raise ValueError(f"unsupported parity fixture schema: {data.get('schema_version')!r}") + cell_cm = float(data["cell_cm"]) + spaces: dict[str, Any] = {} + for case in data["cases"]: + name = str(case["name"]) + if name in spaces: + raise ValueError(f"duplicate parity case: {name}") + segments = [] + for index, source in enumerate(case["segments"]): + thickness = float(source["thickness_cm"]) + if "ray_degrees" in source: + radians = math.radians(float(source["ray_degrees"])) + length = backend.cm_to_units(float(source.get("length_cm", 100)), cell_cm) + a = [0.0, 0.0] + b = [math.cos(radians) * length, math.sin(radians) * length] + else: + a = _point(source["a"], cell_cm=cell_cm, validator=backend) + b = _point(source["b"], cell_cm=cell_cm, validator=backend) + segments.append({"id": f"w{index}", "a": a, "b": b, "cm": thickness}) + spaces[name] = { + "id": name, + "cell_cm": cell_cm, + "rooms": [], + "wall_segments": segments, + "partitions": [], + "room_drafts": [], + "openings": [], + } + if not spaces: + raise ValueError("parity fixture contains no cases") + return spaces + + +def backend_rules(spaces: dict[str, Any], *, validator: Any) -> dict[str, list[str]]: + return { + name: sorted({violation[0] for violation in validator.space_violations(space)}) + for name, space in spaces.items() + } + + +def frontend_rules(spaces: dict[str, Any], *, build_dir: Path) -> dict[str, list[str]]: + junction_module = build_dir / "junction-limits.js" + geometry_module = build_dir / "space-geometry.js" + missing = [str(path) for path in (junction_module, geometry_module) if not path.is_file()] + if missing: + raise FileNotFoundError( + "geometry parity prerequisites are missing: " + ", ".join(missing) + + "; run `npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs`" + ) + env = os.environ.copy() + env["HP_JUNCTION_MODULE"] = junction_module.resolve().as_uri() + env["HP_GEOMETRY_MODULE"] = geometry_module.resolve().as_uri() + try: + result = subprocess.run( + ["node", "--input-type=module", "--eval", _NODE_SCRIPT], + cwd=REPO, + input=json.dumps(spaces), + capture_output=True, + text=True, + check=False, + env=env, + ) + except FileNotFoundError as error: + raise RuntimeError("geometry parity requires Node.js, but `node` was not found") from error + if result.returncode != 0: + raise RuntimeError( + f"TypeScript geometry parity runner failed with exit {result.returncode}:\n{result.stderr}" + ) + return json.loads(result.stdout) + + +def run_parity( + *, + build_dir: Path = DEFAULT_BUILD_DIR, + fixture_path: Path = DEFAULT_FIXTURE, +) -> int: + """Compare all shared scenarios and return their count on success.""" + validator = load_backend_validator() + spaces = load_spaces(fixture_path, validator=validator) + python_result = backend_rules(spaces, validator=validator) + typescript_result = frontend_rules(spaces, build_dir=build_dir) + if typescript_result != python_result: + differences = { + name: {"typescript": typescript_result.get(name), "python": python_result.get(name)} + for name in spaces + if typescript_result.get(name) != python_result.get(name) + } + raise AssertionError( + "TypeScript/Python geometry parity diverged:\n" + + json.dumps(differences, indent=2, ensure_ascii=False) + ) + return len(spaces) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--build-dir", type=Path, default=DEFAULT_BUILD_DIR) + parser.add_argument("--fixture", type=Path, default=DEFAULT_FIXTURE) + args = parser.parse_args(argv) + count = run_parity(build_dir=args.build_dir, fixture_path=args.fixture) + print(f"geometry parity: {count} scenarios executed; TS/Python verdicts identical") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_backend/test_junction_limits.py b/tests_backend/test_junction_limits.py index 02adc16c..40729b1a 100644 --- a/tests_backend/test_junction_limits.py +++ b/tests_backend/test_junction_limits.py @@ -1,19 +1,16 @@ """Issue #329: the backend mirror of the wall-junction limits. Loaded by path, like the other pure backend tests, so Home Assistant is not -needed. The parity test at the bottom is the important one: it feeds the same -fixtures to the TypeScript checks and to this module and demands the same -verdict, because two implementations of one rule are worth nothing if they can -disagree. +needed. Cross-runtime parity is owned by the fail-closed +``tests_backend/junction_parity.py`` executable and its dedicated Validate job +(#548); keeping it here as an optional pytest test was the silent-skip defect. """ import json import os -import subprocess +from pathlib import Path import pytest -from pathlib import Path - from pure_imports import load_pure _ROOT = os.path.dirname(os.path.dirname(__file__)) @@ -203,74 +200,6 @@ def test_a_write_that_adds_a_violation_is_refused_with_a_stable_code(): assert excinfo.value.space_id == "s" -def test_parity_with_the_frontend_checks(): - """The same fixtures must get the same verdict on both sides.""" - fixtures = { - "angle-14": [(*ray(0), 15), (*ray(14), 15)], - "angle-15": [(*ray(0), 15), (*ray(15), 15)], - "valence-7": [(*ray(degree), 15) - for degree in (0, 51, 102, 153, 204, 255, 306)], - "length-19": [([0.0, 0.0], [cm(19), 0.0], 15)], - "length-run": [([0.0, 0.0], [cm(10), 0.0], 15), - ([cm(10), 0.0], [cm(20), 0.0], 15)], - "length-thickness": [([0.0, 0.0], [cm(25), 0.0], 30)], - "distance-4": [([0.0, 0.0], [cm(300), 0.0], 15), - ([0.0, cm(4)], [cm(300), cm(4)], 15)], - "tee": [([0.0, 0.0], [cm(300), 0.0], 15), - ([cm(150), 0.0], [cm(150), cm(300)], 15)], - # #330 M2: границы из плана тестов §7 — их вердикт обязан совпадать - # у зеркал и не зависеть от пути §4.6 (as-is или через миграцию). - "angle-15-exact": [(*ray(0), 15), (*ray(15.0), 15)], - "length-20-exact": [([0.0, 0.0], [cm(20), 0.0], 15)], - "filler-run": [([0.0, 0.0], [cm(349), 0.0], 30), - ([cm(349), 0.0], [cm(354), 0.0], 30), - ([cm(354), 0.0], [cm(554), 0.0], 20)], - "distance-5-exact": [([0.0, 0.0], [cm(300), 0.0], 15), - ([0.0, cm(5)], [cm(300), cm(5)], 15)], - # #331: пограничные классы точности — вердикт зеркал обязан совпасть. - "debris-node": [([-1e-8, 0.0], [cm(300), 0.0], 15), - ([0.0, 0.0], [0.0, cm(300)], 15)], - "duplicate-wall": [([0.0, 0.0], [cm(300), 0.0], 15), - ([0.0, 0.0], [cm(300), 0.0], 15)], - "collinear-fork": [([0.0, 0.0], [cm(100), 0.0], 15), - ([cm(100), 0.0], [cm(160), 0.0], 15), - ([cm(100), 0.0], [cm(220), 1e-9], 15)], - } - payload = {name: space(segments, space_id=name) - for name, segments in fixtures.items()} - mine = {name: rules(sp) for name, sp in payload.items()} - - script = """ -import { checkNodes, checkSegmentLengths, checkNodeDistances } from './test-build/junction-limits.js'; -import { GRID_STEP_N } from './test-build/space-geometry.js'; -let raw = ''; -process.stdin.on('data', (chunk) => { raw += chunk; }); -process.stdin.on('end', () => { - const spaces = JSON.parse(raw); - const out = {}; - for (const [name, space] of Object.entries(spaces)) { - const segments = space.wall_segments; - const violations = [ - ...checkNodes(segments), - ...checkSegmentLengths(segments, space.cell_cm, GRID_STEP_N), - ...checkNodeDistances(segments, space.cell_cm, GRID_STEP_N), - ]; - out[name] = [...new Set(violations.map((item) => item.rule))].sort(); - } - process.stdout.write(JSON.stringify(out)); -}); -""" - if not os.path.isdir(os.path.join(_ROOT, "test-build")): - pytest.skip("test-build/ is not compiled; run npx tsc -p tsconfig.test.json") - result = subprocess.run( - ["node", "--input-type=module", "--eval", script], - cwd=_ROOT, input=json.dumps(payload), capture_output=True, text=True, - check=False, - ) - assert result.returncode == 0, result.stderr - assert json.loads(result.stdout) == mine - - # --- #330: производительность без смены вердиктов --- def test_330_current_version_document_is_judged_as_is(): diff --git a/tests_backend/test_junction_parity.py b/tests_backend/test_junction_parity.py new file mode 100644 index 00000000..82358c2c --- /dev/null +++ b/tests_backend/test_junction_parity.py @@ -0,0 +1,20 @@ +"""Infrastructure contract of the fail-closed junction parity harness (#548).""" +from pathlib import Path + +import pytest + +from junction_parity import DEFAULT_FIXTURE, frontend_rules, load_backend_validator, load_spaces + + +def test_shared_fixture_is_nonempty_and_loads_every_named_scenario(): + spaces = load_spaces(DEFAULT_FIXTURE) + assert len(spaces) == 15 + assert "angle-14" in spaces + assert "distance-5-exact" in spaces + assert "debris-node" in spaces + + +def test_missing_typescript_build_is_a_setup_failure_not_a_skip(tmp_path: Path): + spaces = load_spaces(DEFAULT_FIXTURE, validator=load_backend_validator()) + with pytest.raises(FileNotFoundError, match="prerequisites are missing"): + frontend_rules(spaces, build_dir=tmp_path / "missing-build") diff --git a/tsconfig.junction-parity.json b/tsconfig.junction-parity.json new file mode 100644 index 00000000..202d268d --- /dev/null +++ b/tsconfig.junction-parity.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "esnext", + "outDir": "test-build/junction-parity", + "noEmit": false, + "declaration": false, + "incremental": false + }, + "include": [ + "src/junction-limits.ts" + ] +}