ci: исполнять TS/Python parity на чистом runner (#548)

Issue: #548
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-13 10:49:07 +03:00
parent e85849b86e
commit 76d8017e87
20 changed files with 520 additions and 92 deletions
+57 -2
View File
@@ -216,6 +216,7 @@ jobs:
actions: read actions: read
outputs: outputs:
frontend: ${{ steps.classify.outputs.frontend }} frontend: ${{ steps.classify.outputs.frontend }}
geometry_parity: ${{ steps.classify.outputs.geometry_parity }}
backend: ${{ steps.classify.outputs.backend }} backend: ${{ steps.classify.outputs.backend }}
integration: ${{ steps.classify.outputs.integration }} integration: ${{ steps.classify.outputs.integration }}
perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5 perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5
@@ -370,11 +371,13 @@ jobs:
smoke: ${{ steps.probe.outputs.smoke }} smoke: ${{ steps.probe.outputs.smoke }}
golden: ${{ steps.probe.outputs.golden }} golden: ${{ steps.probe.outputs.golden }}
performance_smoke: ${{ steps.probe.outputs.performance_smoke }} performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
geometry_parity: ${{ steps.probe.outputs.geometry_parity }}
backend: ${{ steps.probe.outputs.backend }} backend: ${{ steps.probe.outputs.backend }}
smoke_key: ${{ steps.keys.outputs.smoke }} smoke_key: ${{ steps.keys.outputs.smoke }}
golden_key: ${{ steps.keys.outputs.golden }} golden_key: ${{ steps.keys.outputs.golden }}
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }} performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }} performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }}
geometry_parity_key: ${{ steps.keys.outputs.geometry_parity }}
backend_key: ${{ steps.keys.outputs.backend }} backend_key: ${{ steps.keys.outputs.backend }}
smoke_source_run: ${{ steps.p_smoke.outputs.source_run }} smoke_source_run: ${{ steps.p_smoke.outputs.source_run }}
smoke_source_attempt: ${{ steps.p_smoke.outputs.source_attempt }} 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_run: ${{ steps.p_perf.outputs.source_run }}
performance_smoke_source_attempt: ${{ steps.p_perf.outputs.source_attempt }} performance_smoke_source_attempt: ${{ steps.p_perf.outputs.source_attempt }}
performance_smoke_source_sha: ${{ steps.p_perf.outputs.source_sha }} 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_run: ${{ steps.p_backend.outputs.source_run }}
backend_source_attempt: ${{ steps.p_backend.outputs.source_attempt }} backend_source_attempt: ${{ steps.p_backend.outputs.source_attempt }}
backend_source_sha: ${{ steps.p_backend.outputs.source_sha }} backend_source_sha: ${{ steps.p_backend.outputs.source_sha }}
@@ -398,7 +404,7 @@ jobs:
PERF_ISO: ${{ needs.changes.outputs.perf_iso }} PERF_ISO: ${{ needs.changes.outputs.perf_iso }}
PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }} PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }}
run: | 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") key=$(node scripts/gate-reuse.mjs --job="$job")
echo "$job=$key" >> "$GITHUB_OUTPUT" echo "$job=$key" >> "$GITHUB_OUTPUT"
echo "$job: $key" echo "$job: $key"
@@ -451,6 +457,19 @@ jobs:
- name: Очистить маркер performance_smoke перед следующим lookup - name: Очистить маркер performance_smoke перед следующим lookup
if: always() if: always()
run: rm -f .reuse-marker 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 - name: Маркер backend
id: m_backend id: m_backend
uses: actions/cache/restore@v6 uses: actions/cache/restore@v6
@@ -470,6 +489,7 @@ jobs:
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }} SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }} GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
PERF: ${{ steps.m_perf.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 }} BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
run: | run: |
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий # Пропуск обязан быть громким: молчаливый skip — тот самый тихий
@@ -488,6 +508,7 @@ jobs:
waive smoke "$SMOKE" waive smoke "$SMOKE"
waive golden "$GOLDEN" waive golden "$GOLDEN"
waive performance_smoke "$PERF" waive performance_smoke "$PERF"
waive geometry_parity "$GEOMETRY_PARITY"
waive backend "$BACKEND" waive backend "$BACKEND"
hacs: hacs:
@@ -1048,6 +1069,40 @@ jobs:
path: .reuse-marker path: .reuse-marker
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }} 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: backend:
name: "Бэкенд: pytest в Home Assistant" name: "Бэкенд: pytest в Home Assistant"
needs: [changes, reuse] needs: [changes, reuse]
@@ -1137,7 +1192,7 @@ jobs:
proof: proof:
name: "Доказательство выполненных проверок" name: "Доказательство выполненных проверок"
if: always() 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 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
+4 -2
View File
@@ -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 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) `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, 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 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 the candidate; `publish-prerelease.yml` and `release.yml` refuse a candidate
without the `Release:` trailer, so a green Validate without the heavy jobs can 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 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 an unfinished Validate for the same branch. Gate jobs, matching the actual
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`, `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 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 screenshots `sourceFingerprint` against current `src/**`, which is exactly what
went red after the #113 merge. A stable release additionally waits for Full went red after the #113 merge. A stable release additionally waits for Full
+3
View File
@@ -633,6 +633,9 @@ npm run golden:verify # если менялся визуал
node scripts/check-docs.mjs # если менялся src/** node scripts/check-docs.mjs # если менялся src/**
node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки
python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:check), если менялся бэкенд 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 вхождения явного **Новый код не добавляет `any`** (#342). В `src/**` уже 1034 вхождения явного
+7
View File
@@ -2034,6 +2034,13 @@ not the feeling that "editor text should be lazy".
- The backend CI job measures branch coverage (pure + HA harness combined), - The backend CI job measures branch coverage (pure + HA harness combined),
fails below `scripts/backend-coverage-baseline.txt` and refuses to run when fails below `scripts/backend-coverage-baseline.txt` and refuses to run when
the HA harness would silently skip. 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: - `const.ERROR_CODES` / `ERROR_CODE_FAMILIES` are THE stable error contract:
the scanner test proves every emitted code (send_error literals, exception the scanner test proves every emitted code (send_error literals, exception
class attrs, literal and variable-passed MarkerControlError codes, f-string class attrs, literal and variable-passed MarkerControlError codes, f-string
+5
View File
@@ -130,6 +130,11 @@ git config core.untrackedCache true
`$env:PYTEST_DISABLE_PLUGIN_AUTOLOAD='1'`. `$env:PYTEST_DISABLE_PLUGIN_AUTOLOAD='1'`.
- Full backend (including `test_ha_*.py`): `python -m pytest tests_backend/ -q` - Full backend (including `test_ha_*.py`): `python -m pytest tests_backend/ -q`
in CI or WSL/Linux only. 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 - 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`, 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`. which fails on such errors. Always build with `npm run build`, never bare `rollup -c`.
+11
View File
@@ -324,6 +324,17 @@ export const CHECKS = {
roots: [...BUILD_INPUTS, 'demo/performance/**', ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW], roots: [...BUILD_INPUTS, 'demo/performance/**', ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW],
reuse: true, 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: { backend: {
// pytest tests_backend, unittest scripts/support-relay/tests, ruff/mypy, порог покрытия // pytest tests_backend, unittest scripts/support-relay/tests, ruff/mypy, порог покрытия
entries: ['tests_backend/**/*.py', 'scripts/support-relay/tests/**/*.py', 'scripts/dump-config-schema.py'], entries: ['tests_backend/**/*.py', 'scripts/support-relay/tests/**/*.py', 'scripts/dump-config-schema.py'],
+4
View File
@@ -36,6 +36,7 @@ const JOB_RULES = Object.freeze({
], ],
golden: [{ exact: 'Golden-кадры против принятых эталонов', count: 1 }], golden: [{ exact: 'Golden-кадры против принятых эталонов', count: 1 }],
performance_smoke: [{ exact: 'Перф-смок: бюджет времени кадра', count: 1 }], performance_smoke: [{ exact: 'Перф-смок: бюджет времени кадра', count: 1 }],
geometry_parity: [{ exact: 'Геометрия: TS/Python parity исполнена', count: 1 }],
backend: [{ exact: 'Бэкенд: pytest в Home Assistant', 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(selection.integration)) ids.push('integration');
if (asBool(request.mutants)) ids.push('mutants'); if (asBool(request.mutants)) ids.push('mutants');
if (asBool(request.full)) ids.push('smoke', 'golden', 'performance_smoke'); 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'); if (asBool(selection.backend)) ids.push('backend');
return ids; return ids;
} }
@@ -90,6 +92,7 @@ export function buildCiProof({
}; };
const selection = { const selection = {
frontend: asBool(changes.frontend), frontend: asBool(changes.frontend),
geometry_parity: asBool(changes.geometry_parity),
backend: asBool(changes.backend), backend: asBool(changes.backend),
integration: asBool(changes.integration), integration: asBool(changes.integration),
}; };
@@ -124,6 +127,7 @@ export function buildCiProof({
executedOrReused('golden'); executedOrReused('golden');
executedOrReused('performance_smoke'); executedOrReused('performance_smoke');
} }
if (selection.geometry_parity) executedOrReused('geometry_parity');
if (selection.backend) executedOrReused('backend'); if (selection.backend) executedOrReused('backend');
const requiredChecks = requiredCheckIds({ request, selection }); const requiredChecks = requiredCheckIds({ request, selection });
return { return {
+1
View File
@@ -27,6 +27,7 @@ import { checksAffectedBy } from './check-inputs.mjs';
*/ */
export const CHECK_OF_OUTPUT = { export const CHECK_OF_OUTPUT = {
frontend: 'frontend', frontend: 'frontend',
geometry_parity: 'geometry_parity',
backend: 'backend', backend: 'backend',
integration: 'integration', integration: 'integration',
mutants: 'changed_mutants', mutants: 'changed_mutants',
+5 -3
View File
@@ -1,6 +1,7 @@
// Ключ переиспользования результата тяжёлой job (issue #208). // Ключ переиспользования результата тяжёлой job (issue #208).
// //
// Тяжёлые job Validate — smoke, golden, performance_smoke, backend — прогонялись // Reusable job Validate — smoke, golden, performance_smoke, geometry_parity,
// backend — прогонялись
// на каждый push в dev, включая коммиты в документацию, workflows и процессные // на каждый push в dev, включая коммиты в документацию, workflows и процессные
// скрипты, где бандл и оснастка побайтово те же. Ключ здесь отвечает на один // скрипты, где бандл и оснастка побайтово те же. Ключ здесь отвечает на один
// вопрос: «менялось ли хоть что-то, от чего результат этой job зависит». // вопрос: «менялось ли хоть что-то, от чего результат этой job зависит».
@@ -17,8 +18,9 @@
// пишет только успешный прогон с тем же ключом. // пишет только успешный прогон с тем же ключом.
// //
// Свойство, которое стоит знать: релизный кандидат (бета или стабильный релиз) // Свойство, которое стоит знать: релизный кандидат (бета или стабильный релиз)
// бампает версию — `package.json`/`CARD_VERSION` у браузерных job и // бампает версию — `package.json`/`CARD_VERSION` у браузерных job,
// `custom_components/houseplan/manifest.json` у backend входят в manifest. // `package.json` у geometry_parity и `custom_components/houseplan/manifest.json`
// у backend входят в manifest.
// Значит ключ кандидата заведомо новый и полный набор гейтов прогоняется всегда. // Значит ключ кандидата заведомо новый и полный набор гейтов прогоняется всегда.
// Переиспользование физически не может ослабить релизный гейт. // Переиспользование физически не может ослабить релизный гейт.
+13
View File
@@ -3202,6 +3202,19 @@ const MUTANT_DEFINITIONS = [
+ '`${point[0].toFixed(6)},${point[1].toFixed(6)}`;', + '`${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', id: 'junction-limit-branch-dropped',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+15 -1
View File
@@ -122,7 +122,7 @@ test('замыкание останавливается на копиях бан
}); });
test('каждая проверка объявлена, у тяжёлых job включён реюз, у остальных нет', () => { 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) { for (const name of CHECK_NAMES) {
assert.ok(MANIFEST[name].size > 0, `${name}: пустой manifest`); assert.ok(MANIFEST[name].size > 0, `${name}: пустой manifest`);
assert.ok(Array.isArray(CHECKS[name].entries) && Array.isArray(CHECKS[name].roots), name); assert.ok(Array.isArray(CHECKS[name].entries) && Array.isArray(CHECKS[name].roots), name);
@@ -132,6 +132,16 @@ test('каждая проверка объявлена, у тяжёлых job в
test('§8.1 представители: каждая категория каждой тяжёлой job — её вход', () => { test('§8.1 представители: каждая категория каждой тяжёлой job — её вход', () => {
const expect = { 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: { backend: {
source: ['custom_components/houseplan/websocket_api.py', 'scripts/support-relay/relay.py', 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'], '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']) { for (const job of ['smoke', 'golden', 'performance_smoke']) {
assert.ok(![...MANIFEST[job]].some((f) => f.startsWith('tests_backend/')), `${job} читает tests_backend`); 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', () => { test('§5.5 лист покрытия: ни одного неизвестного исполняемого файла, ни одной лишней записи NOT_AN_INPUT', () => {
+7 -3
View File
@@ -20,6 +20,7 @@ const names = {
smokeDone: 'Смоки: все шарды зелёные', smokeDone: 'Смоки: все шарды зелёные',
golden: 'Golden-кадры против принятых эталонов', golden: 'Golden-кадры против принятых эталонов',
performance: 'Перф-смок: бюджет времени кадра', performance: 'Перф-смок: бюджет времени кадра',
geometryParity: 'Геометрия: TS/Python parity исполнена',
backend: 'Бэкенд: pytest в Home Assistant', backend: 'Бэкенд: pytest в Home Assistant',
}; };
@@ -33,7 +34,7 @@ const smokeJobs = () => Array.from({ length: 3 }, (_, index) => (
export function proofFixture({ export function proofFixture({
id = 10, attempt = 2, full = true, mutants = true, 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 = { const needs = {
preflight: { result: 'success' }, preflight: { result: 'success' },
@@ -41,7 +42,8 @@ export function proofFixture({
result: 'success', result: 'success',
outputs: { outputs: {
heavy: String(full), mutants_requested: String(mutants), 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: {} }, reuse: { result: 'success', outputs: {} },
@@ -53,6 +55,7 @@ export function proofFixture({
smoke_done: { result: full ? 'success' : 'skipped' }, smoke_done: { result: full ? 'success' : 'skipped' },
golden: { result: full ? 'success' : 'skipped' }, golden: { result: full ? 'success' : 'skipped' },
performance_smoke: { result: full ? 'success' : 'skipped' }, performance_smoke: { result: full ? 'success' : 'skipped' },
geometry_parity: { result: geometryParity ? 'success' : 'skipped' },
backend: { result: backend ? 'success' : 'skipped' }, backend: { result: backend ? 'success' : 'skipped' },
}; };
const proof = buildCiProof({ const proof = buildCiProof({
@@ -64,6 +67,7 @@ export function proofFixture({
if (integration) jobs.push(success(names.hacs), success(names.hassfest)); if (integration) jobs.push(success(names.hacs), success(names.hassfest));
if (mutants) jobs.push(...mutantJobs()); if (mutants) jobs.push(...mutantJobs());
if (full) jobs.push(...smokeJobs(), success(names.smokeDone), success(names.golden), success(names.performance)); 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)); if (backend) jobs.push(success(names.backend));
const run = { const run = {
databaseId: id, attempt, status: 'completed', conclusion, 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, [ assert.deepEqual(fixture.proof.requiredChecks, [
'preflight', 'changes', 'reuse', 'frontend', 'integration', 'mutants', '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)); assert.deepEqual(fixture.proof.requiredChecks, requiredCheckIds(fixture.proof));
}); });
+14
View File
@@ -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', () => { test('#492 AC6: правка UI не классифицируется как backend', () => {
const out = classify(['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts']); const out = classify(['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts']);
assert.equal(out.backend, 'false'); assert.equal(out.backend, 'false');
+115
View File
@@ -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 }
]
}
]
}
+13 -1
View File
@@ -27,13 +27,19 @@ const makeTree = () => {
put('package-lock.json', '{"lockfileVersion":3}\n'); put('package-lock.json', '{"lockfileVersion":3}\n');
put('rollup.config.mjs', 'export default {};\n'); put('rollup.config.mjs', 'export default {};\n');
put('tsconfig.json', '{}\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('.github/workflows/validate.yml', 'name: Validate\n');
put('scripts/source-fingerprint.mjs', '// pinned by the real repo copy\n'); put('scripts/source-fingerprint.mjs', '// pinned by the real repo copy\n');
put('scripts/gate-reuse.mjs', '// reuse protocol\n'); put('scripts/gate-reuse.mjs', '// reuse protocol\n');
put('scripts/check-inputs.mjs', '// manifest\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/card.ts', "export const CARD_VERSION = '1.0.0';\n");
put('src/plan-optimizer.ts', 'export const PLAN_MODEL_VERSION = 1;\n'); put('src/plan-optimizer.ts', 'export const PLAN_MODEL_VERSION = 1;\n');
put('src/logic.ts', 'export const logic = 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/serve.mjs', "import './bundle-freshness.mjs';\n");
put('demo/bundle-freshness.mjs', 'export const fresh = 1;\n'); put('demo/bundle-freshness.mjs', 'export const fresh = 1;\n');
put('demo/srv/demo.html', '<div id="host"></div>\n'); put('demo/srv/demo.html', '<div id="host"></div>\n');
@@ -51,7 +57,12 @@ const makeTree = () => {
put('demo/performance/compare.mjs', 'export const cmp = 1;\n'); put('demo/performance/compare.mjs', 'export const cmp = 1;\n');
put('demo/performance/budgets-glow-smoke.json', '{"hardMaxMs":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/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('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/store.py', 'VERSION = 1\n');
put('custom_components/houseplan/manifest.json', '{"domain":"houseplan","version":"1.0.0"}\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'); 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', () => { test('every heavy job has non-empty inputs and its own key', () => {
const { dir } = makeTree(); const { dir } = makeTree();
try { try {
assert.deepEqual(JOBS, ['smoke', 'golden', 'performance_smoke', 'backend']); assert.deepEqual(JOBS, ['smoke', 'golden', 'performance_smoke', 'geometry_parity', 'backend']);
const k = keys(dir); const k = keys(dir);
for (const job of JOBS) assert.ok(harnessFiles(dir, job).length > 0, job); for (const job of JOBS) assert.ok(harnessFiles(dir, job).length > 0, job);
assert.equal(new Set(Object.values(k)).size, JOBS.length, 'ключи 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', only(['golden'])(() => put('demo/golden/baselines/one.png',
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x02]))); Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x02])));
only(['performance_smoke'])(() => put('demo/performance/compare.mjs', 'export const cmp = 2;\n')); 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')); only(['backend'])(() => put('custom_components/houseplan/store.py', 'VERSION = 2\n'));
// Протокол браузерного харнеса общий для трёх job (#492 §5.1 protocol). // Протокол браузерного харнеса общий для трёх job (#492 §5.1 protocol).
only(['smoke', 'golden', 'performance_smoke'])(() => put('demo/serve.mjs', "import './bundle-freshness.mjs';\n// harness\n")); only(['smoke', 'golden', 'performance_smoke'])(() => put('demo/serve.mjs', "import './bundle-freshness.mjs';\n// harness\n"));
+33 -5
View File
@@ -91,7 +91,7 @@ test('джобы с полной историей качают её без бл
// job `changes`, поэтому имя job ищется только с начала строки. // job `changes`, поэтому имя job ищется только с начала строки.
const start = workflow.indexOf(`\n ${job}:\n`); const start = workflow.indexOf(`\n ${job}:\n`);
assert.ok(start > 0, `нет job ${job}`); 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'/, assert.match(chunk, /fetch-depth: 0, filter: 'blob:none'/,
`${job} обязана качать историю без блобов`); `${job} обязана качать историю без блобов`);
} }
@@ -193,7 +193,7 @@ test('перф-смок добавляет профиль ровно при св
assert.ok(!changes.includes("printf 'frontend=true"), 'ручной список выходов в fallback-е запрещён'); assert.ok(!changes.includes("printf 'frontend=true"), 'ручной список выходов в fallback-е запрещён');
const start = workflow.indexOf('\n performance_smoke:\n'); 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\]/); assert.match(job, /needs: \[changes, frontend, reuse\]/);
const iso = job.slice(job.indexOf('Изометрический профиль по диффу'), job.indexOf('Профиль взаимодействия по диффу')); const iso = job.slice(job.indexOf('Изометрический профиль по диффу'), job.indexOf('Профиль взаимодействия по диффу'));
assert.match(iso, /if: needs\.changes\.outputs\.perf_iso == 'true'/); assert.match(iso, /if: needs\.changes\.outputs\.perf_iso == 'true'/);
@@ -208,6 +208,34 @@ test('перф-смок добавляет профиль ровно при св
assert.ok(!/\n\s+if:/.test(glow), 'glow-профили гоняются всегда'); 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)', () => { test('ключ reuse перф-смока различает наборы профилей (#473 AC5)', () => {
const workflow = read('validate.yml'); const workflow = read('validate.yml');
const reuse = workflow.slice(workflow.indexOf('\n reuse:\n'), workflow.indexOf('\n hacs:\n')); 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 создаётся и на красном прогоне'); assert.match(proof, /if: always\(\)/, 'proof создаётся и на красном прогоне');
for (const dependency of [ for (const dependency of [
'preflight', 'changes', 'reuse', 'hacs', 'hassfest', 'changed_mutants', '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, new RegExp(`needs: \\[[^\\n]*\\b${dependency}\\b`), dependency);
assert.match(proof, /CANDIDATE_SHA: \$\{\{ github\.sha \}\}/); assert.match(proof, /CANDIDATE_SHA: \$\{\{ github\.sha \}\}/);
assert.match(proof, /CANDIDATE_TREE: \$\{\{ steps\.candidate\.outputs\.tree \}\}/); 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 \}\}/); 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')); 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_run:`), `${id}: source run output`);
assert.match(reuse, new RegExp(`${id}_source_attempt:`), `${id}: source attempt 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.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, assert.equal(reuse.includes('lookup-only: true'), false,
'marker contents must be restored and verified, not reduced to a cache-hit bit'); 'marker contents must be restored and verified, not reduced to a cache-hit bit');
}); });
+176
View File
@@ -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())
+4 -75
View File
@@ -1,19 +1,16 @@
"""Issue #329: the backend mirror of the wall-junction limits. """Issue #329: the backend mirror of the wall-junction limits.
Loaded by path, like the other pure backend tests, so Home Assistant is not 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 needed. Cross-runtime parity is owned by the fail-closed
fixtures to the TypeScript checks and to this module and demands the same ``tests_backend/junction_parity.py`` executable and its dedicated Validate job
verdict, because two implementations of one rule are worth nothing if they can (#548); keeping it here as an optional pytest test was the silent-skip defect.
disagree.
""" """
import json import json
import os import os
import subprocess from pathlib import Path
import pytest import pytest
from pathlib import Path
from pure_imports import load_pure from pure_imports import load_pure
_ROOT = os.path.dirname(os.path.dirname(__file__)) _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" 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: производительность без смены вердиктов --- # --- #330: производительность без смены вердиктов ---
def test_330_current_version_document_is_judged_as_is(): def test_330_current_version_document_is_judged_as_is():
+20
View File
@@ -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")
+13
View File
@@ -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"
]
}