mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 22:29:05 +00:00
ci: исполнять TS/Python parity на чистом runner (#548)
Issue: #548 User-Visible: no
This commit is contained in:
@@ -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.
|
||||
|
||||
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():
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user