mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
The limits read `wall_segments`, so a document older than the catalogue reports no walls at all — and therefore no violations, whatever its geometry. Comparing that raw baseline against a candidate the card had already migrated counted every inherited violation as new, and a legacy plan could not take an unrelated edit at all: renaming a room was refused with junction_limit_angle. Spec §3 forbids exactly this, and the frontend had already learned the same lesson in 4758767e; the backend mirror simply never got the second half. validate_junction_limits now runs both documents through commit_wall_segment_model before counting. A document that cannot be migrated is not this validator's verdict — the wall-model barrier owns that error and reports it with its own code — so it degrades to "no baseline to inherit". The regression is pinned twice: a test that asserts the legacy baseline reads clean raw and carries the apex once migrated, and the mutant junction-limit-backend-raw-baseline. Both fixtures that exercise the barrier were rebuilt as real documents (rooms plus walls), because the previous ones put walls in wall_segments with no rooms and did not survive migration. Issue: #329 User-Visible: no
264 lines
9.4 KiB
Python
264 lines
9.4 KiB
Python
"""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.
|
|
"""
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
_ROOT = os.path.dirname(os.path.dirname(__file__))
|
|
_PACKAGE_ROOT = os.path.join(_ROOT, "custom_components")
|
|
_HOUSEPLAN_ROOT = os.path.join(_PACKAGE_ROOT, "houseplan")
|
|
|
|
if "custom_components" not in sys.modules:
|
|
package = types.ModuleType("custom_components")
|
|
package.__path__ = [_PACKAGE_ROOT]
|
|
sys.modules["custom_components"] = package
|
|
if "custom_components.houseplan" not in sys.modules:
|
|
package = types.ModuleType("custom_components.houseplan")
|
|
package.__path__ = [_HOUSEPLAN_ROOT]
|
|
sys.modules["custom_components.houseplan"] = package
|
|
|
|
# Loaded under its canonical package name: the module imports the migration
|
|
# mirror relatively (`from .wall_segment_model import ...`), which only resolves
|
|
# when the module knows the package it belongs to.
|
|
_PATH = os.path.join(_HOUSEPLAN_ROOT, "junction_limits.py")
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"custom_components.houseplan.junction_limits", _PATH,
|
|
)
|
|
jl = importlib.util.module_from_spec(_spec)
|
|
sys.modules[_spec.name] = jl
|
|
_spec.loader.exec_module(jl)
|
|
|
|
CELL = 5.0
|
|
|
|
|
|
def cm(value):
|
|
return jl.cm_to_units(value, CELL)
|
|
|
|
|
|
def space(segments, space_id="s", cell_cm=CELL):
|
|
return {
|
|
"id": space_id,
|
|
"cell_cm": cell_cm,
|
|
"rooms": [],
|
|
"wall_segments": [
|
|
{"id": f"w{index}", "a": list(a), "b": list(b), "cm": thickness}
|
|
for index, (a, b, thickness) in enumerate(segments)
|
|
],
|
|
"partitions": [],
|
|
"room_drafts": [],
|
|
"openings": [],
|
|
}
|
|
|
|
|
|
def ray(degrees, length_cm=100.0):
|
|
radians = degrees * 3.141592653589793 / 180
|
|
import math
|
|
return ([0.0, 0.0], [math.cos(radians) * cm(length_cm),
|
|
math.sin(radians) * cm(length_cm)])
|
|
|
|
|
|
def rules(sp):
|
|
return sorted({item[0] for item in jl.space_violations(sp)})
|
|
|
|
|
|
def test_min_angle_is_a_hard_15_degrees():
|
|
below = space([(*ray(0), 15), (*ray(14), 15)])
|
|
at = space([(*ray(0), 15), (*ray(15), 15)])
|
|
assert "angle" in rules(below)
|
|
assert "angle" not in rules(at)
|
|
|
|
|
|
def test_valence_allows_six_walls_and_refuses_the_seventh():
|
|
six = space([(*ray(degree), 15) for degree in (0, 60, 120, 180, 240, 300)])
|
|
seven = space([(*ray(degree), 15) for degree in (0, 51, 102, 153, 204, 255, 306)])
|
|
assert "valence" not in rules(six)
|
|
assert "valence" in rules(seven)
|
|
|
|
|
|
def test_length_measures_the_wall_run_not_the_atom():
|
|
# Two collinear 15 cm atoms of 10 cm each are ONE 20 cm wall — legal,
|
|
# exactly the thickness-step compensation case from the owner's fixture.
|
|
run = space([
|
|
([0.0, 0.0], [cm(10), 0.0], 15),
|
|
([cm(10), 0.0], [cm(20), 0.0], 15),
|
|
])
|
|
assert "length" not in rules(run)
|
|
# A lone 19 cm wall is not.
|
|
short = space([([0.0, 0.0], [cm(19), 0.0], 15)])
|
|
assert "length" in rules(short)
|
|
# A wall may never be shorter than its own thickness.
|
|
thick = space([([0.0, 0.0], [cm(25), 0.0], 30)])
|
|
assert "length" in rules(thick)
|
|
|
|
|
|
def test_distance_keeps_the_t_joint_legal():
|
|
near = space([
|
|
([0.0, 0.0], [cm(300), 0.0], 15),
|
|
([0.0, cm(4)], [cm(300), cm(4)], 15),
|
|
])
|
|
assert "distance" in rules(near)
|
|
tee = space([
|
|
([0.0, 0.0], [cm(300), 0.0], 15),
|
|
([cm(150), 0.0], [cm(150), cm(300)], 15),
|
|
])
|
|
assert "distance" not in rules(tee)
|
|
|
|
|
|
def room_space(rooms, walls, space_id="s", cell_cm=CELL, legacy=True):
|
|
"""A space in the shape a real document has: rooms plus their walls.
|
|
|
|
`legacy=True` stores the walls the pre-catalogue way (`walls`, no
|
|
`wall_segments`) — the state every plan is in before its first structural
|
|
write on a current card, and the state H1 was about.
|
|
"""
|
|
space = {
|
|
"id": space_id, "title": "L", "cell_cm": cell_cm, "view_box": [0, 0, 1, 1],
|
|
"rooms": rooms, "openings": [], "room_drafts": [],
|
|
"partitions": [], "wall_columns": [],
|
|
}
|
|
if legacy:
|
|
space["walls"] = walls
|
|
else:
|
|
space["wall_segments"] = walls
|
|
return space
|
|
|
|
|
|
def triangle(apex_x=0.3167, apex_y=0.24):
|
|
"""The owner's spike: an apex well under 15°."""
|
|
return [[0.30, 0.70], [apex_x, apex_y], [0.36, 0.68]]
|
|
|
|
|
|
def square(x=0.60, y=0.60, side=0.20):
|
|
return [[x, y], [x + side, y], [x + side, y + side], [x, y + side]]
|
|
|
|
|
|
def walls_of(poly, prefix, cm_value=15):
|
|
return [
|
|
{"key": f"{prefix}{index}", "a": poly[index],
|
|
"b": poly[(index + 1) % len(poly)], "cm": cm_value}
|
|
for index in range(len(poly))
|
|
]
|
|
|
|
|
|
def test_legacy_baseline_is_judged_after_the_same_migration():
|
|
"""H1 (r1): a pre-catalogue baseline must not read as "no violations".
|
|
|
|
`limit_segments` reads `wall_segments`, so a legacy space answers "clean"
|
|
whatever its geometry. Comparing that against a candidate the client has
|
|
already migrated counted every inherited violation as new and refused an
|
|
unrelated edit — spec §3 forbids exactly that.
|
|
"""
|
|
spike = triangle()
|
|
previous = {"spaces": [room_space(
|
|
[{"id": "r1", "name": "a", "area": None, "poly": spike}],
|
|
walls_of(spike, "w"),
|
|
)]}
|
|
# Raw, the legacy baseline claims to be clean...
|
|
assert rules(previous["spaces"][0]) == []
|
|
# ...while the same document, migrated, carries the inherited apex.
|
|
migrated, _ = jl.commit_wall_segment_model(json.loads(json.dumps(previous)))
|
|
assert "angle" in rules(migrated["spaces"][0])
|
|
|
|
# An unrelated edit (renaming the room) on the migrated candidate passes.
|
|
candidate = json.loads(json.dumps(migrated))
|
|
candidate["spaces"][0]["rooms"][0]["name"] = "b"
|
|
jl.validate_junction_limits(candidate, previous)
|
|
|
|
|
|
def test_inherited_violation_does_not_block_an_unrelated_edit():
|
|
spike = triangle()
|
|
previous = {"spaces": [room_space(
|
|
[{"id": "r1", "name": "a", "area": None, "poly": spike}],
|
|
walls_of(spike, "w"),
|
|
)]}
|
|
# Adding a well-formed room next to the broken one is a legal write.
|
|
box = square()
|
|
candidate = json.loads(json.dumps(previous))
|
|
candidate["spaces"][0]["rooms"].append(
|
|
{"id": "r2", "name": "box", "area": None, "poly": box}
|
|
)
|
|
candidate["spaces"][0]["walls"].extend(walls_of(box, "b"))
|
|
jl.validate_junction_limits(candidate, previous)
|
|
|
|
|
|
def test_a_write_that_adds_a_violation_is_refused_with_a_stable_code():
|
|
box = square()
|
|
previous = {"spaces": [room_space(
|
|
[{"id": "r1", "name": "box", "area": None, "poly": box}],
|
|
walls_of(box, "b"),
|
|
)]}
|
|
jl.validate_junction_limits(json.loads(json.dumps(previous)), previous)
|
|
|
|
spike = triangle()
|
|
candidate = json.loads(json.dumps(previous))
|
|
candidate["spaces"][0]["rooms"].append(
|
|
{"id": "r2", "name": "spike", "area": None, "poly": spike}
|
|
)
|
|
candidate["spaces"][0]["walls"].extend(walls_of(spike, "w"))
|
|
with pytest.raises(jl.JunctionLimitError) as excinfo:
|
|
jl.validate_junction_limits(candidate, previous)
|
|
assert excinfo.value.code == "junction_limit_angle"
|
|
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)],
|
|
}
|
|
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
|