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
+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.
Loaded by path, like the other pure backend tests, so Home Assistant is not
needed. The parity test at the bottom is the important one: it feeds the same
fixtures to the TypeScript checks and to this module and demands the same
verdict, because two implementations of one rule are worth nothing if they can
disagree.
needed. Cross-runtime parity is owned by the fail-closed
``tests_backend/junction_parity.py`` executable and its dedicated Validate job
(#548); keeping it here as an optional pytest test was the silent-skip defect.
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
from pathlib import Path
from pure_imports import load_pure
_ROOT = os.path.dirname(os.path.dirname(__file__))
@@ -203,74 +200,6 @@ def test_a_write_that_adds_a_violation_is_refused_with_a_stable_code():
assert excinfo.value.space_id == "s"
def test_parity_with_the_frontend_checks():
"""The same fixtures must get the same verdict on both sides."""
fixtures = {
"angle-14": [(*ray(0), 15), (*ray(14), 15)],
"angle-15": [(*ray(0), 15), (*ray(15), 15)],
"valence-7": [(*ray(degree), 15)
for degree in (0, 51, 102, 153, 204, 255, 306)],
"length-19": [([0.0, 0.0], [cm(19), 0.0], 15)],
"length-run": [([0.0, 0.0], [cm(10), 0.0], 15),
([cm(10), 0.0], [cm(20), 0.0], 15)],
"length-thickness": [([0.0, 0.0], [cm(25), 0.0], 30)],
"distance-4": [([0.0, 0.0], [cm(300), 0.0], 15),
([0.0, cm(4)], [cm(300), cm(4)], 15)],
"tee": [([0.0, 0.0], [cm(300), 0.0], 15),
([cm(150), 0.0], [cm(150), cm(300)], 15)],
# #330 M2: границы из плана тестов §7 — их вердикт обязан совпадать
# у зеркал и не зависеть от пути §4.6 (as-is или через миграцию).
"angle-15-exact": [(*ray(0), 15), (*ray(15.0), 15)],
"length-20-exact": [([0.0, 0.0], [cm(20), 0.0], 15)],
"filler-run": [([0.0, 0.0], [cm(349), 0.0], 30),
([cm(349), 0.0], [cm(354), 0.0], 30),
([cm(354), 0.0], [cm(554), 0.0], 20)],
"distance-5-exact": [([0.0, 0.0], [cm(300), 0.0], 15),
([0.0, cm(5)], [cm(300), cm(5)], 15)],
# #331: пограничные классы точности — вердикт зеркал обязан совпасть.
"debris-node": [([-1e-8, 0.0], [cm(300), 0.0], 15),
([0.0, 0.0], [0.0, cm(300)], 15)],
"duplicate-wall": [([0.0, 0.0], [cm(300), 0.0], 15),
([0.0, 0.0], [cm(300), 0.0], 15)],
"collinear-fork": [([0.0, 0.0], [cm(100), 0.0], 15),
([cm(100), 0.0], [cm(160), 0.0], 15),
([cm(100), 0.0], [cm(220), 1e-9], 15)],
}
payload = {name: space(segments, space_id=name)
for name, segments in fixtures.items()}
mine = {name: rules(sp) for name, sp in payload.items()}
script = """
import { checkNodes, checkSegmentLengths, checkNodeDistances } from './test-build/junction-limits.js';
import { GRID_STEP_N } from './test-build/space-geometry.js';
let raw = '';
process.stdin.on('data', (chunk) => { raw += chunk; });
process.stdin.on('end', () => {
const spaces = JSON.parse(raw);
const out = {};
for (const [name, space] of Object.entries(spaces)) {
const segments = space.wall_segments;
const violations = [
...checkNodes(segments),
...checkSegmentLengths(segments, space.cell_cm, GRID_STEP_N),
...checkNodeDistances(segments, space.cell_cm, GRID_STEP_N),
];
out[name] = [...new Set(violations.map((item) => item.rule))].sort();
}
process.stdout.write(JSON.stringify(out));
});
"""
if not os.path.isdir(os.path.join(_ROOT, "test-build")):
pytest.skip("test-build/ is not compiled; run npx tsc -p tsconfig.test.json")
result = subprocess.run(
["node", "--input-type=module", "--eval", script],
cwd=_ROOT, input=json.dumps(payload), capture_output=True, text=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == mine
# --- #330: производительность без смены вердиктов ---
def test_330_current_version_document_is_judged_as_is():
+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")