Files
houseplan-card/tests_backend/test_wall_segment_model.py
T
Codex 0f36ef55d5 fix: an opening without an in-place carrier migrates unhosted (#316, review r1 H1 + r2 M2)
CODE-REVIEW-316-r1 H1: the §3.3 degraded pool picked an angle-compatible wall
at ANY distance, but the backend geometry-match invariant («wall opening
geometry must match its host») requires the host to agree with the opening's
own x/y — the migrated document was rejected by CONFIG_SCHEMA and the write
wedged again on the schema layer. The pool is removed from both migrations
(TS and the Python mirror): without an in-place eligible carrier the opening
goes straight to the unhosted degraded state, exactly the alternative the
spec's «assumed freely changeable» section reserved; the spec is revision 6.
New tests replay the reviewer's reproduction on both sides, and the frontend
test is proven able to fail by restoring the pool (executed red).

CODE-REVIEW-316-r2 M2: the schema-level host check is shared with #132
partition openings, so its unhosted relaxation is now pinned by a regression
test — a stale writer that keeps a partition-hosted opening but silently
drops its host is still rejected by validate_partition_opening_hosts.

Issue: #316
User-Visible: no
2026-08-26 23:41:35 +03:00

499 lines
20 KiB
Python

"""Stored wall identity and frontend/backend migration parity (#282)."""
from __future__ import annotations
import copy
import json
import math
from pathlib import Path
import pytest
from custom_components.houseplan.validation import (
CONFIG_SCHEMA,
PartitionOpeningHostError,
WallModelClientOutdatedError,
validate_partition_opening_hosts,
validate_wall_model_transition,
)
from custom_components.houseplan.wall_segment_model import (
WALL_SEGMENT_MODEL_VERSION,
WallSegmentMigrationError,
commit_wall_segment_model,
deterministic_wall_segment_id,
)
def _room(room_id: str, x1=0.0, y1=0.0, x2=1.0, y2=1.0) -> dict:
return {
"id": room_id, "name": room_id,
"poly": [[x1, y1], [x2, y1], [x2, y2], [x1, y2]],
}
def _config(space: dict) -> dict:
return {"spaces": [{"title": "Floor", "view_box": [0, 0, 1, 1], **space}],
"markers": [], "settings": {}}
def test_backend_migration_matches_shared_frontend_parity_fixture() -> None:
fixture = json.loads((
Path(__file__).parents[1] / "test" / "fixtures" / "282-wall-identity-parity.json"
).read_text(encoding="utf-8"))
migrated, _ = commit_wall_segment_model(fixture["input"])
space = migrated["spaces"][0]
assert space["rooms"][0]["wall_ids"] == fixture["expected"]["large_wall_ids"]
assert space["rooms"][1]["wall_ids"] == fixture["expected"]["small_wall_ids"]
assert space["openings"][0]["host"] == fixture["expected"]["opening_host"]
assert [segment["id"] for segment in space["room_drafts"][0]["segments"]] \
== fixture["expected"]["draft_ids"]
def test_hash_and_endpoint_order_match_frontend_vector() -> None:
expected = "wall-qweriry5umvd3ywku3iv"
assert deterministic_wall_segment_id(
"floor", [0, 0], [1, 0], ["room-b", "room-a"]
) == expected
assert deterministic_wall_segment_id(
"floor", [1, 0], [0, 0], ["room-a", "room-b"]
) == expected
def test_migration_is_pure_complete_idempotent_and_schema_valid() -> None:
source = _config({"id": "floor", "rooms": [_room("room")]})
original = copy.deepcopy(source)
first, count = commit_wall_segment_model(source)
second, second_count = commit_wall_segment_model(first)
assert source == original
assert first["model_version"] == WALL_SEGMENT_MODEL_VERSION
assert count == 4
assert len(first["spaces"][0]["wall_segments"]) == 4
assert len(first["spaces"][0]["rooms"][0]["wall_ids"]) == 4
assert second_count == 0
assert second == first
assert CONFIG_SCHEMA(first) == first
def test_zero_wall_identity_follows_current_carrier_without_phantom_breakpoints() -> None:
baseline, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
}))
candidate = copy.deepcopy(baseline)
candidate["spaces"][0]["rooms"][0]["poly"] = [
[0, 0], [1, 0], [1, 1.25], [0, 1.25],
]
migrated, _ = commit_wall_segment_model(candidate)
space = migrated["spaces"][0]
assert space["rooms"][0]["poly"] == candidate["spaces"][0]["rooms"][0]["poly"]
assert len(space["rooms"][0]["poly"]) == 4
assert len(space["wall_segments"]) == 4
assert all(float(segment["cm"]) == 0 for segment in space["wall_segments"])
def test_partial_shared_boundary_has_exactly_one_two_owner_atom() -> None:
migrated, _ = commit_wall_segment_model(_config({
"id": "floor",
"rooms": [_room("large"), _room("small", 1, 0.25, 1.5, 0.75)],
}))
space = migrated["spaces"][0]
owners = {segment["id"]: 0 for segment in space["wall_segments"]}
for room in space["rooms"]:
for segment_id in room["wall_ids"]:
owners[segment_id] += 1
assert set(owners.values()) <= {1, 2}
assert list(owners.values()).count(2) == 1
def test_corner_opening_on_bodyless_walls_migrates_unhosted() -> None:
"""#316 §3.3/§3.4: the initial migration degrades instead of blocking.
Since #306 an unconfigured contour is explicitly bodyless (cm 0) and a
zero wall is never an opening carrier, so the corner door persists
unhosted; the migration never throws over an opening.
"""
source = _config({
"id": "floor", "rooms": [_room("room")],
"openings": [{
"id": "door", "type": "door", "x": 0, "y": 0,
"angle": 0, "length": 0.2,
}],
})
original = copy.deepcopy(source)
migrated, _ = commit_wall_segment_model(source)
assert source == original
assert "host" not in migrated["spaces"][0]["openings"][0]
assert commit_wall_segment_model(migrated)[0] == migrated
def test_post_v8_new_atoms_are_random_and_promoted_draft_id_survives() -> None:
base, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
}))
base["spaces"][0]["rooms"].append({
"id": "promoted", "name": "promoted",
"poly": [[2, 0], [3, 0], [3, 1], [2, 1]],
"wall_ids": ["draft-top", "", "", ""],
})
migrated, _ = commit_wall_segment_model(base)
promoted = migrated["spaces"][0]["rooms"][1]
assert promoted["wall_ids"][0] == "draft-top"
assert promoted["wall_ids"][1].startswith("wall-")
assert promoted["wall_ids"][1] != deterministic_wall_segment_id(
"floor", [3, 0], [3, 1], ["promoted"]
)
def test_promoted_divider_outweighs_stale_room_edge_ordinal() -> None:
base, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("parent")],
}))
old_ids = base["spaces"][0]["rooms"][0]["wall_ids"]
base["spaces"][0]["rooms"] = [{
"id": "parent", "name": "parent",
"poly": [[0, 0], [0.5, 0], [0.5, 1], [0, 1]],
"wall_ids": old_ids,
}, {
"id": "child", "name": "child",
"poly": [[0.5, 0], [1, 0], [1, 1], [0.5, 1]],
"wall_ids": ["", "", "", "draft-divider"],
}]
result, _ = commit_wall_segment_model(base)
parent, child = result["spaces"][0]["rooms"]
assert parent["wall_ids"][1] == "draft-divider"
assert child["wall_ids"][3] == "draft-divider"
def test_initial_id_collision_uses_documented_suffix() -> None:
base_id = deterministic_wall_segment_id("floor", [0, 0], [1, 0], ["room"])
migrated, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
"partitions": [{"id": base_id, "a": [2, 0], "b": [3, 0], "cm": 15}],
}))
assert migrated["spaces"][0]["rooms"][0]["wall_ids"][0] == f"{base_id}-2"
def test_v8_schema_rejects_stale_room_reference_and_projection() -> None:
migrated, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
}))
stale_reference = copy.deepcopy(migrated)
stale_reference["spaces"][0]["rooms"][0]["wall_ids"][0] = "missing"
with pytest.raises(Exception, match="wall id"):
CONFIG_SCHEMA(stale_reference)
stale_projection = copy.deepcopy(migrated)
stale_projection["spaces"][0]["walls"] = [{
"key": "0,0@0", "a": [0, 0], "b": [1, 0], "cm": 15,
}]
with pytest.raises(Exception, match="match wall_segments"):
CONFIG_SCHEMA(stale_projection)
def test_stale_client_round_trip_is_hydrated_but_structural_change_is_rejected() -> None:
previous, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
}))
legacy = copy.deepcopy(previous)
legacy.pop("model_version")
legacy["spaces"][0].pop("wall_segments")
for room in legacy["spaces"][0]["rooms"]:
room.pop("wall_ids")
validate_wall_model_transition(legacy, previous)
assert legacy == previous
changed = copy.deepcopy(legacy)
changed.pop("model_version")
changed["spaces"][0].pop("wall_segments")
for room in changed["spaces"][0]["rooms"]:
room.pop("wall_ids")
changed["spaces"][0]["rooms"][0]["poly"][0][0] = 0.25
with pytest.raises(WallModelClientOutdatedError):
validate_wall_model_transition(changed, previous)
validate_wall_model_transition(
{"model_version": 8, "spaces": [], "markers": [], "settings": {}},
{"model_version": 8, "spaces": [], "markers": [], "settings": {}},
)
def test_stale_client_echoing_v8_catalog_gets_the_named_error() -> None:
previous, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
}))
stale = copy.deepcopy(previous)
stale["spaces"][0]["rooms"][0]["poly"][0][0] = 0.25
# A real stale card echoes unknown v8 fields unchanged rather than
# explicitly downgrading model_version.
with pytest.raises(WallModelClientOutdatedError, match="unchanged wall catalogue"):
validate_wall_model_transition(stale, previous)
non_structural = copy.deepcopy(previous)
non_structural["settings"]["language"] = "ru"
validate_wall_model_transition(non_structural, previous)
def test_first_write_of_a_newer_model_is_not_outdated_even_without_catalog_change() -> None:
"""#319: a client can only echo the stored model_version, never raise it.
The pair fixture is generated by real writers: `stored` by the
v1.68.0-beta.2 (model v8) writer with one orphan open_span, `sent` by the
current initial migration — the catalogue is byte-identical, the orphan
span is dropped. Before the fix this exact pair raised
WallModelClientOutdatedError and wedged every structural write forever.
"""
fixture = json.loads((
Path(__file__).parents[1] / "test" / "fixtures"
/ "319-orphan-span-migration.json"
).read_text(encoding="utf-8"))
stored, sent = fixture["stored"], fixture["sent"]
assert stored["model_version"] == 8 and sent["model_version"] == 9
assert stored["spaces"][0]["wall_segments"] == sent["spaces"][0]["wall_segments"]
assert "open_spans" in stored["spaces"][0]
assert "open_spans" not in sent["spaces"][0]
# AC1: the pure migration write passes.
validate_wall_model_transition(copy.deepcopy(sent), stored)
# AC2: the same write with the user's first draft (the first click of the
# Walls tool, independent geometry per #314) passes too.
with_draft = copy.deepcopy(sent)
with_draft["spaces"][0].setdefault("room_drafts", []).append({
"id": "d-319", "points": [[0.7, 0.7], [0.8, 0.7]],
"segments": [{"id": "seg-d-319", "cm": 20}],
})
validate_wall_model_transition(with_draft, stored)
# AC3: the same-version echo (9 → 9) with changed contours and an
# unchanged catalogue keeps the named refusal.
echoed = copy.deepcopy(sent)
echoed["spaces"][0]["rooms"][0]["poly"][0][0] += 0.01
with pytest.raises(WallModelClientOutdatedError, match="unchanged wall catalogue"):
validate_wall_model_transition(echoed, copy.deepcopy(sent))
def test_current_wall_model_independent_geometry_does_not_require_contour_catalog_change() -> None:
"""Drafts, partitions, columns and hosted openings own their identity (#314)."""
previous, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
# #306 makes every unconfigured contour atom explicitly bodyless.
# The opening branch of this #314 identity test therefore needs one
# real host; otherwise the v9 validator correctly rejects it.
"walls": [{"key": "bottom", "a": [0, 0], "b": [1, 0], "cm": 15}],
}))
previous_catalog = copy.deepcopy(previous["spaces"][0]["wall_segments"])
candidates = []
draft = copy.deepcopy(previous)
draft["spaces"][0]["room_drafts"] = [{
"id": "draft-new", "points": [[2, 0], [3, 0]],
"segments": [{"id": "draft-segment-new", "cm": 15}],
}]
candidates.append(draft)
partition = copy.deepcopy(previous)
partition["spaces"][0]["partitions"] = [{
"id": "partition-new", "a": [2, 0], "b": [3, 0], "cm": 15,
}]
candidates.append(partition)
column = copy.deepcopy(previous)
column["spaces"][0]["wall_columns"] = [{
"id": "column-new", "shape": "square", "center": [2, 2],
"cm": 30, "angle": 0,
}]
candidates.append(column)
opening = copy.deepcopy(previous)
host = next(
segment for segment in opening["spaces"][0]["wall_segments"]
if float(segment["cm"]) > 0
)
dx = float(host["b"][0]) - float(host["a"][0])
dy = float(host["b"][1]) - float(host["a"][1])
opening["spaces"][0]["openings"] = [{
"id": "opening-new", "type": "door",
"x": (float(host["a"][0]) + float(host["b"][0])) / 2,
"y": (float(host["a"][1]) + float(host["b"][1])) / 2,
"angle": math.degrees(math.atan2(dy, dx)), "length": 0.2,
"host": {"kind": "wall", "id": host["id"], "t": 0.5},
}]
candidates.append(opening)
for candidate in candidates:
assert candidate["spaces"][0]["wall_segments"] == previous_catalog
validate_wall_model_transition(candidate, previous)
assert CONFIG_SCHEMA(candidate) == candidate
def test_downgraded_independent_partition_round_trip_is_hydrated() -> None:
previous, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
}))
legacy = copy.deepcopy(previous)
legacy.pop("model_version")
legacy["spaces"][0].pop("wall_segments")
for room in legacy["spaces"][0]["rooms"]:
room.pop("wall_ids")
legacy["spaces"][0]["partitions"] = [{
"id": "partition-legacy", "a": [2, 0], "b": [3, 0], "cm": 15,
}]
validate_wall_model_transition(legacy, previous)
assert legacy["model_version"] == WALL_SEGMENT_MODEL_VERSION
assert legacy["spaces"][0]["wall_segments"] == previous["spaces"][0]["wall_segments"]
assert CONFIG_SCHEMA(legacy) == legacy
def test_v8_open_span_migrates_to_zero_atoms_and_removes_legacy_fields() -> None:
base, _ = commit_wall_segment_model(_config({
"id": "floor",
"rooms": [_room("left", 0, 0, 0.5, 1), _room("right", 0.5, 0, 1, 1)],
}))
space = base["spaces"][0]
for segment in space["wall_segments"]:
segment["cm"] = 15
space["walls"] = [{
"key": f"legacy-{index}", "a": copy.deepcopy(segment["a"]),
"b": copy.deepcopy(segment["b"]), "cm": 15,
} for index, segment in enumerate(space["wall_segments"])]
base["model_version"] = 8
space["open_spans"] = [{"a": [0.5, 0.25], "b": [0.5, 0.75]}]
for room in space["rooms"]:
room["open_to"] = ["right" if room["id"] == "left" else "left"]
migrated, _ = commit_wall_segment_model(base)
migrated_space = migrated["spaces"][0]
shared_ids = set(migrated_space["rooms"][0]["wall_ids"]).intersection(
migrated_space["rooms"][1]["wall_ids"]
)
shared = [segment for segment in migrated_space["wall_segments"]
if segment["id"] in shared_ids]
assert migrated["model_version"] == 9
assert "open_spans" not in migrated_space
assert all("open_to" not in room for room in migrated_space["rooms"])
assert sorted(segment["cm"] for segment in shared) == [0.0, 15.0, 15.0]
assert commit_wall_segment_model(migrated)[0] == migrated
def test_v8_open_span_over_an_opening_spares_the_carrying_atom() -> None:
base, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
}))
space = base["spaces"][0]
bottom_id = space["rooms"][0]["wall_ids"][0]
for segment in space["wall_segments"]:
segment["cm"] = 15
space["walls"] = [{
"key": f"legacy-{index}", "a": copy.deepcopy(segment["a"]),
"b": copy.deepcopy(segment["b"]), "cm": 15,
} for index, segment in enumerate(space["wall_segments"])]
space["openings"] = [{
"id": "door", "type": "door", "x": 0.5, "y": 0,
"angle": 0, "length": 0.2,
"host": {"kind": "wall", "id": bottom_id, "t": 0.5},
}]
space["open_spans"] = [{"a": [0, 0], "b": [1, 0]}]
base["model_version"] = 8
before = copy.deepcopy(base)
# #316 §3.1: the atom carrying the hosted door keeps its thickness while
# the rest of the span run turns to zero on both sides of the opening.
migrated, _ = commit_wall_segment_model(base)
assert base == before
space = migrated["spaces"][0]
bottom = sorted(
(segment for segment in space["wall_segments"]
if abs(segment["a"][1]) < 1e-9 and abs(segment["b"][1]) < 1e-9),
key=lambda segment: segment["a"][0],
)
assert [segment["cm"] for segment in bottom] == [0.0, 15.0, 0.0]
host = space["openings"][0]["host"]
assert host["kind"] == "wall" and host["id"] == bottom[1]["id"]
assert "open_spans" not in space
assert commit_wall_segment_model(migrated)[0] == migrated
def test_unhosted_contour_opening_is_a_valid_degraded_v9_state() -> None:
"""#316 AC4: schema and repeat writes keep the unhosted opening."""
source = _config({
"id": "floor", "rooms": [_room("room")],
"openings": [{
"id": "orphan", "type": "door", "x": 0.9, "y": 0.65,
"angle": 90, "length": 0.05,
}],
})
migrated, _ = commit_wall_segment_model(source)
opening = migrated["spaces"][0]["openings"][0]
assert "host" not in opening
validated = CONFIG_SCHEMA(copy.deepcopy(migrated))
assert "host" not in validated["spaces"][0]["openings"][0]
assert commit_wall_segment_model(migrated)[0] == migrated
def test_post_v9_write_that_lost_its_carrier_keeps_the_refusal() -> None:
"""#316 AC5: only the INITIAL migration degrades; a v9 write fails closed."""
base, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
}))
space = base["spaces"][0]
space["openings"] = [{
"id": "door", "type": "door", "x": 0.9, "y": 0.65,
"angle": 90, "length": 0.05,
"host": {"kind": "wall", "id": "wall-gone", "t": 0.5},
}]
with pytest.raises(WallSegmentMigrationError, match="opening-host"):
commit_wall_segment_model(base)
def test_far_same_angle_wall_is_not_a_degraded_carrier() -> None:
"""CODE-REVIEW-316-r1 H1: a distant host would break the schema invariant
«wall opening geometry must match its host» and wedge the write again.
The opening must migrate unhosted and the migrated document must pass
CONFIG_SCHEMA."""
base, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
}))
space = base["spaces"][0]
for segment in space["wall_segments"]:
segment["cm"] = 15
space["walls"] = [{
"key": f"legacy-{index}", "a": copy.deepcopy(segment["a"]),
"b": copy.deepcopy(segment["b"]), "cm": 15,
} for index, segment in enumerate(space["wall_segments"])]
# Same angle as the bottom wall (y=0), but far away from every wall.
space["openings"] = [{
"id": "far", "type": "door", "x": 0.5, "y": 0.5,
"angle": 0, "length": 0.2,
}]
base["model_version"] = 8
migrated, _ = commit_wall_segment_model(base)
opening = migrated["spaces"][0]["openings"][0]
assert "host" not in opening
validated = CONFIG_SCHEMA(copy.deepcopy(migrated))
assert "host" not in validated["spaces"][0]["openings"][0]
assert commit_wall_segment_model(migrated)[0] == migrated
def test_dropping_a_partition_host_is_still_rejected_after_the_unhosted_relaxation() -> None:
"""CODE-REVIEW-316-r2 M2: the schema now tolerates a missing host (the
unhosted contour state of #316 §3.3), so the #132 protection against a
stale writer silently dropping a PARTITION host must keep holding on the
semantic layer — pinned here so a future relaxation cannot slip through.
"""
base, _ = commit_wall_segment_model(_config({
"id": "floor", "rooms": [_room("room")],
"partitions": [{"id": "p1", "a": [0.2, 0.5], "b": [0.8, 0.5], "cm": 10}],
"openings": [{
"id": "pdoor", "type": "door", "x": 0.5, "y": 0.5,
"angle": 0, "length": 0.2,
"host": {"kind": "partition", "id": "p1", "t": 0.5},
}],
}))
stale = copy.deepcopy(base)
del stale["spaces"][0]["openings"][0]["host"]
with pytest.raises(PartitionOpeningHostError):
validate_partition_opening_hosts(stale, base)