Files
houseplan-card/tests_backend/test_led_strips.py
T
Codex ef3e7a5440 fix(config): an ordinary save without led_strips keeps the stored shapes (#780)
r1 H1: a previous frontend sends every space without the unknown field and
the ordinary write path replaced the document with it, erasing every LED
strip. The shared ordinary-writer helper now copies the stored shapes of a
space the payload omits (config/set and Optimize); an explicit list from a
new client still deletes, a removed space takes its strips, and a link to a
marker the same write deleted unbinds by the usual rule. config/set reports
the normalisation counters after the preservation.

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00

322 lines
13 KiB
Python
Executable File

"""#780: LED strip storage — schema, write-path normalisation, transfer rules.
Pure suite (no Home Assistant): ``validation.py`` and ``led_strips.py`` are
loaded by path. The import/export paths that need the HA harness live in
``test_ha_import_export.py``.
"""
from __future__ import annotations
import copy
import json
from pathlib import Path
import pytest
import voluptuous as vol
from pure_imports import load_pure
_HOUSEPLAN = Path(__file__).resolve().parent.parent / "custom_components" / "houseplan"
v = load_pure("hp_validation_led", _HOUSEPLAN / "validation.py")
led = load_pure("custom_components.houseplan.led_strips", _HOUSEPLAN / "led_strips.py")
support = load_pure("custom_components.houseplan.support_package", _HOUSEPLAN / "support_package.py")
wsm = load_pure("custom_components.houseplan.wall_segment_model", _HOUSEPLAN / "wall_segment_model.py")
PLAN_MODEL_VERSION = 10
def _space(space_id: str, title: str) -> dict:
return {
"id": space_id, "title": title, "view_box": [0, 0, 1, 1],
"rooms": [{"id": f"{space_id}-room", "name": title, "poly": [[0, 0], [1, 0], [1, 1]]}],
}
def _config(strips=None, markers=None, *, space_id: str = "ground", extra_spaces=()) -> dict:
"""A current (v10) document: built from a v9 one by the real migration."""
legacy = {
"model_version": PLAN_MODEL_VERSION - 1,
"spaces": [_space(space_id, "Ground"), *(_space(s, s.title()) for s in extra_spaces)],
"markers": markers if markers is not None else [
{"id": "lamp", "binding": "entity:light.kitchen", "space": space_id},
],
}
config = wsm.commit_wall_segment_model(legacy)[0]
if strips is not None:
config["spaces"][0]["led_strips"] = strips
return config
def _strip(**extra) -> dict:
return {"id": "led-1", "points": [[0.1, 0.1], [0.4, 0.1], [0.4, 0.3]], "marker": "lamp", **extra}
def _check(config: dict) -> dict:
return v.CONFIG_SCHEMA(copy.deepcopy(config))
def _strips_of(config: dict) -> list[dict]:
return config["spaces"][0]["led_strips"]
# ---------- AC1: valid geometry is stored ----------
def test_open_and_closed_strips_are_stored_exactly() -> None:
closed = {"id": "led-2", "points": [[0, 0], [0.2, 0], [0.2, 0.2], [0, 0]], "marker": None}
checked = _check(_config([_strip(), closed]))
stored = _strips_of(checked)
assert stored[0]["points"] == [[0.1, 0.1], [0.4, 0.1], [0.4, 0.3]]
assert stored[0]["marker"] == "lamp"
assert stored[1]["points"][0] == stored[1]["points"][-1]
assert led.strip_is_closed(stored[1]["points"])
def test_absent_field_equals_no_strips_and_needs_no_version_bump() -> None:
checked = _check(_config())
assert "led_strips" not in checked["spaces"][0]
assert led.led_strip_counts(checked) == {"led_strips": 0, "led_strips_unbound": 0, "led_strips_hidden": 0}
@pytest.mark.parametrize("points, reason", [
([[0, 0]], "one point"),
([[0, 0], [0, 0]], "zero length"),
([[0, 0], [0, 0], [0, 0]], "zero length, repeated"),
([[0, 0], [0.2, 0], [0, 0]], "closed with two distinct vertices"),
([["0", 0], [1, 1]], "string coordinate"),
([[True, 0], [1, 1]], "boolean coordinate"),
([[float("nan"), 0], [1, 1]], "NaN"),
([[float("inf"), 0], [1, 1]], "Infinity"),
([[0, 0], [6000, 0]], "outside the canvas"),
([[0, 0, 0], [1, 1]], "three coordinates"),
("[[0,0],[1,1]]", "not a list"),
])
def test_invalid_geometry_rejects_the_whole_write(points, reason) -> None:
config = _config([_strip(points=points)])
before = copy.deepcopy(config)
with pytest.raises(vol.Invalid):
_check(config)
assert config == before, f"{reason}: a rejected write must not be half-applied"
def test_point_and_strip_limits_are_50_inclusive() -> None:
fifty = [[i / 100, (i % 2) / 100] for i in range(50)]
assert len(_strips_of(_check(_config([_strip(points=fifty)])))[0]["points"]) == 50
with pytest.raises(vol.Invalid):
_check(_config([_strip(points=fifty + [[0.9, 0.9]])]))
strips = [{"id": f"led-{i}", "points": [[0, i / 100], [0.1, i / 100]], "marker": None} for i in range(50)]
assert len(_strips_of(_check(_config(strips)))) == 50
strips.append({"id": "led-50", "points": [[0, 0.9], [0.1, 0.9]], "marker": None})
with pytest.raises(vol.Invalid):
_check(_config(strips))
def test_hidden_shapes_count_toward_the_strip_limit() -> None:
markers = [{"id": f"m{i}", "binding": "virtual", "space": "ground"} for i in range(51)]
strips = [
{"id": f"led-{i}", "points": [[0, i / 100], [0.1, i / 100]], "marker": f"m{i}", "active": False}
for i in range(51)
]
with pytest.raises(vol.Invalid):
_check(_config(strips, markers))
def test_duplicate_strip_id_within_a_space_rejects() -> None:
other = {"id": "led-1", "points": [[0.5, 0.5], [0.6, 0.5]], "marker": None}
with pytest.raises(vol.Invalid, match="unique"):
_check(_config([_strip(), other]))
@pytest.mark.parametrize("active", ["true", 1, 0, None])
def test_active_is_strictly_boolean(active) -> None:
with pytest.raises(vol.Invalid):
_check(_config([_strip(active=active)]))
def test_hidden_shape_must_belong_to_a_marker() -> None:
with pytest.raises(vol.Invalid, match="belong to a marker"):
_check(_config([_strip(marker=None, active=False)]))
@pytest.mark.parametrize("marker", ["", 5, ["lamp"]])
def test_marker_reference_must_be_a_non_empty_string_or_null(marker) -> None:
with pytest.raises(vol.Invalid):
_check(_config([_strip(marker=marker)]))
# ---------- AC1: duplicates and foreign spaces reject, before any normalisation ----------
def test_one_marker_cannot_be_bound_to_two_strips_even_hidden() -> None:
second = {"id": "led-2", "points": [[0.5, 0.5], [0.6, 0.5]], "marker": "lamp", "active": False}
with pytest.raises(vol.Invalid, match="more than one LED strip"):
_check(_config([_strip(), second]))
def test_two_links_to_the_same_missing_marker_are_still_a_conflict() -> None:
first = _strip(marker="gone")
second = {"id": "led-2", "points": [[0.5, 0.5], [0.6, 0.5]], "marker": "gone"}
with pytest.raises(vol.Invalid, match="more than one LED strip"):
_check(_config([first, second]))
def test_duplicates_across_spaces_are_judged_globally() -> None:
config = _config([_strip()], extra_spaces=("upper",))
config["spaces"][1]["led_strips"] = [{"id": "led-1", "points": [[0, 0], [1, 0]], "marker": "lamp"}]
with pytest.raises(vol.Invalid, match="more than one LED strip"):
_check(config)
def test_marker_of_another_space_rejects() -> None:
markers = [{"id": "lamp", "binding": "entity:light.kitchen", "space": "upper"}]
with pytest.raises(vol.Invalid, match="another space"):
_check(_config([_strip()], markers, extra_spaces=("upper",)))
# ---------- §9: normalisation of an old client's write ----------
@pytest.mark.parametrize("active", [True, False])
def test_deleted_marker_leaves_an_unbound_strip_with_its_geometry(active) -> None:
"""A client that does not know strips deletes the bound marker: the save stands."""
config = _config([_strip(active=active)], markers=[])
report = led.led_strip_link_report(config)
checked = _check(config)
strip = _strips_of(checked)[0]
assert strip["id"] == "led-1"
assert strip["points"] == [[0.1, 0.1], [0.4, 0.1], [0.4, 0.3]]
assert strip["marker"] is None
assert strip["active"] is True
assert report == {"unbound": 1, "space_adopted": 0}
def _old_writer(config: dict) -> dict:
"""The payload of a frontend that does not know the field (r1 H1)."""
payload = copy.deepcopy(config)
for space in payload["spaces"]:
space.pop("led_strips", None)
return payload
def _ordinary(candidate: dict, previous: dict) -> dict:
return v.prepare_ordinary_summary_candidate(copy.deepcopy(candidate), previous, set(), v.CONFIG_SCHEMA)
@pytest.mark.parametrize("active", [True, False])
def test_old_writer_omitting_the_field_keeps_the_stored_shapes(active) -> None:
"""r1 H1: an omitted ``led_strips`` is not a deletion on the ordinary write path."""
previous = _check(_config([_strip(active=active)]))
checked = _ordinary(_old_writer(previous), previous)
assert _strips_of(checked) == _strips_of(previous)
def test_old_writer_deleting_the_marker_unbinds_the_kept_shape() -> None:
previous = _check(_config([_strip(active=False)]))
payload = _old_writer(previous)
payload["markers"] = []
strip = _strips_of(_ordinary(payload, previous))[0]
assert strip == {**_strip(), "marker": None, "active": True}
def test_explicit_empty_list_deletes_and_a_removed_space_takes_its_shapes() -> None:
previous = _check(_config([_strip()], extra_spaces=("upper",)))
explicit = copy.deepcopy(previous)
explicit["spaces"][0]["led_strips"] = []
assert _strips_of(_ordinary(explicit, previous)) == []
removed = _old_writer(previous)
removed["spaces"] = [space for space in removed["spaces"] if space["id"] != "ground"]
removed["markers"] = []
checked = _ordinary(removed, previous)
assert all("led_strips" not in space for space in checked["spaces"])
# The preservation never invents a key for a space that had none.
assert "led_strips" not in _ordinary(_old_writer(_check(_config())), _check(_config()))["spaces"][0]
def test_tombstoned_marker_is_not_live() -> None:
markers = [{"id": "lamp", "binding": "entity:light.kitchen", "space": "ground", "removed": True}]
strip = _strips_of(_check(_config([_strip()], markers)))[0]
assert strip["marker"] is None and strip["active"] is True
@pytest.mark.parametrize("space", ["absent", None, ""])
def test_empty_marker_space_adopts_the_strip_space(space) -> None:
marker = {"id": "lamp", "binding": "entity:light.kitchen"}
if space != "absent":
marker["space"] = space
config = _config([_strip()], [marker])
assert led.led_strip_link_report(config) == {"unbound": 0, "space_adopted": 1}
checked = _check(config)
assert checked["markers"][0]["space"] == "ground"
assert _strips_of(checked)[0]["marker"] == "lamp"
def test_normalisation_is_idempotent() -> None:
config = _config([_strip(active=False)], markers=[])
once = _check(config)
twice = _check(once)
assert once == twice
assert led.led_strip_link_report(once) == {"unbound": 0, "space_adopted": 0}
def test_unavailable_entity_is_not_a_missing_marker() -> None:
"""Temporary HA unavailability is a state, never a config fact: the link stays."""
strip = _strips_of(_check(_config([_strip()])))[0]
assert strip["marker"] == "lamp"
def test_points_carry_only_json_noise_cleanup_not_a_lattice_snap() -> None:
face = 0.4123456789 # a physical wall face, off the plan lattice
strip = _strip(points=[[0.1, face], [0.30000000000000004, face]])
stored = _strips_of(_check(_config([strip])))[0]["points"]
assert stored == [[0.1, 0.412345679], [0.3, 0.412345679]]
# ---------- transfer rules ----------
def test_plan_only_projection_keeps_geometry_and_drops_every_device_link() -> None:
space = {"led_strips": [
_strip(),
{"id": "led-2", "points": [[0, 0], [1, 0]], "marker": "fan", "active": False, "future": "x"},
]}
projected = led.plan_only_strips(space)
assert projected == [
{"id": "led-1", "points": [[0.1, 0.1], [0.4, 0.1], [0.4, 0.3]], "marker": None, "active": True},
{"id": "led-2", "points": [[0, 0], [1, 0]], "marker": None, "active": True},
]
assert "lamp" not in json.dumps(projected) and "fan" not in json.dumps(projected)
assert led.plan_only_strips({}) is None
def test_transfer_remaps_links_and_unbinds_what_did_not_travel() -> None:
space = {"led_strips": [
_strip(),
{"id": "led-2", "points": [[0, 0], [1, 0]], "marker": "skipped", "active": False},
{"id": "led-3", "points": [[0, 1], [1, 1]], "marker": None},
]}
unbound = led.unbind_strips(space, remap={"lamp": "marker_lamp_2"})
strips = space["led_strips"]
assert strips[0]["marker"] == "marker_lamp_2"
assert strips[1] == {"id": "led-2", "points": [[0, 0], [1, 0]], "marker": None, "active": True}
assert strips[2]["marker"] is None
assert unbound == 1
def test_transfer_never_binds_by_a_coinciding_old_id() -> None:
"""The target has its own marker 'lamp': without a remap entry the strip unbinds."""
space = {"led_strips": [_strip()]}
assert led.unbind_strips(space, remap={}) == 1
assert space["led_strips"][0]["marker"] is None
# ---------- diagnostics privacy ----------
def test_support_summary_counts_strips_without_coordinates_or_ids() -> None:
config = _config([
_strip(),
{"id": "led-secret-id", "points": [[0.123, 0.456], [0.789, 0.456]], "marker": None},
])
config["markers"].append({"id": "hall", "binding": "entity:light.hall", "space": "ground"})
config["spaces"][0]["led_strips"].append(
{"id": "led-3", "points": [[0, 0], [1, 0]], "marker": "hall", "active": False},
)
summary = support._summary(config, {})
assert summary["led_strips"] == {"total": 3, "unbound": 1, "hidden": 1}
text = json.dumps(summary)
for secret in ("led-secret-id", "0.123", "0.789", "light.kitchen", "light.hall"):
assert secret not in text