mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
ci: исполнять TS/Python parity на чистом runner (#548)
Issue: #548 User-Visible: no
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 вхождения явного
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.
|
||||||
// Значит ключ кандидата заведомо новый и полный набор гейтов прогоняется всегда.
|
// Значит ключ кандидата заведомо новый и полный набор гейтов прогоняется всегда.
|
||||||
// Переиспользование физически не может ослабить релизный гейт.
|
// Переиспользование физически не может ослабить релизный гейт.
|
||||||
|
|
||||||
|
|||||||
@@ -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 '
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user