Release v1.62.0-beta.2 candidate

This commit is contained in:
Matysh
2026-08-11 22:12:08 +03:00
parent 2cf5c2748e
commit 554d2e6544
98 changed files with 5386 additions and 2106 deletions
+15
View File
@@ -9,3 +9,18 @@ except ImportError:
HAS_HA = False
collect_ignore_glob = [] if HAS_HA else ["test_ha_*.py"]
if HAS_HA:
import shutil
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def _clean_persistent_houseplan_test_files(request):
"""Do not let warm HA harness runs exhaust the shared file quota."""
if "hass" in request.fixturenames:
hass = request.getfixturevalue("hass")
for relative in ("houseplan/plans", "houseplan/files"):
shutil.rmtree(Path(hass.config.path(relative)), ignore_errors=True)
yield
+180 -2
View File
@@ -26,9 +26,12 @@ from custom_components.houseplan.import_export import (
prepare_apply,
revalidate_candidate,
)
from custom_components.houseplan import import_export as import_export_api
from custom_components.houseplan import websocket_api as wsapi
from custom_components.houseplan.http_api import HouseplanImportPreviewView, KEY_HASS
from custom_components.houseplan.const import (
DOMAIN,
MAX_IMPORT_PREVIEWS_PER_USER,
MAX_IMPORT_PREVIEWS_TOTAL,
PLAN_MODEL_VERSION,
PLANS_DIR,
@@ -202,6 +205,78 @@ def test_full_export_has_versioned_envelope_and_live_layout(tmp_path: Path) -> N
assert filename.startswith("houseplan-full-") and filename.endswith(".json")
def test_full_export_import_round_trip_restores_model_version(tmp_path: Path) -> None:
"""The portable envelope version must return to the persisted config."""
document = _document(tmp_path)
runtime = SimpleNamespace(instance_id="instance-a", import_previews={})
response = create_preview(
runtime, json.dumps(document).encode(), owner_id="alice", duplicate_policy="skip",
current_config_data={"config": {"spaces": [], "markers": []}, "rev": 0},
current_layout_data={"layout": {}, "rev": 0}, config_root=tmp_path,
)
candidate = get_candidate(runtime, response["token"], "alice")
config, _layout, _details = prepare_apply(
candidate, {"spaces": [], "markers": []}, {}, confirm_missing_content=False,
)
assert config["model_version"] == document["model_version"] == PLAN_MODEL_VERSION
assert config == _config()
def test_preview_tokens_are_bounded_owned_expiring_and_single_use(
tmp_path: Path, monkeypatch,
) -> None:
runtime = SimpleNamespace(instance_id="instance-a", import_previews={})
raw = json.dumps(_document(tmp_path)).encode()
# Separate the two equal production limits so this assertion still kills a
# mutant that accidentally removes only the per-user eviction branch.
monkeypatch.setattr(
import_export_api, "MAX_IMPORT_PREVIEWS_TOTAL", MAX_IMPORT_PREVIEWS_PER_USER + 5,
)
tokens = []
for _index in range(MAX_IMPORT_PREVIEWS_PER_USER + 1):
response = create_preview(
runtime, raw, owner_id="alice", duplicate_policy="skip",
current_config_data={"config": _config(), "rev": 1},
current_layout_data={"layout": {}, "rev": 1}, config_root=tmp_path,
)
tokens.append(response["token"])
assert len(runtime.import_previews) == MAX_IMPORT_PREVIEWS_PER_USER
assert tokens[0] not in runtime.import_previews
live = tokens[-1]
with pytest.raises(ImportFailure) as wrong_owner:
get_candidate(runtime, live, "bob")
assert wrong_owner.value.code == "preview_owner_mismatch"
get_candidate(runtime, live, "alice", consume=True)
with pytest.raises(ImportFailure) as consumed:
get_candidate(runtime, live, "alice")
assert consumed.value.code == "preview_expired"
expiring = create_preview(
runtime, raw, owner_id="alice", duplicate_policy="skip",
current_config_data={"config": _config(), "rev": 1},
current_layout_data={"layout": {}, "rev": 1}, config_root=tmp_path,
)["token"]
runtime.import_previews[expiring]["expires"] = 0
with pytest.raises(ImportFailure) as expired:
get_candidate(runtime, expiring, "alice")
assert expired.value.code == "preview_expired"
assert expiring not in runtime.import_previews
monkeypatch.setattr(import_export_api, "MAX_IMPORT_PREVIEWS_TOTAL", MAX_IMPORT_PREVIEWS_TOTAL)
global_runtime = SimpleNamespace(instance_id="instance-a", import_previews={})
global_tokens = [
create_preview(
global_runtime, raw, owner_id=f"owner-{index}", duplicate_policy="skip",
current_config_data={"config": _config(), "rev": 1},
current_layout_data={"layout": {}, "rev": 1}, config_root=tmp_path,
)["token"]
for index in range(MAX_IMPORT_PREVIEWS_TOTAL + 1)
]
assert len(global_runtime.import_previews) == MAX_IMPORT_PREVIEWS_TOTAL
assert global_tokens[0] not in global_runtime.import_previews
@pytest.mark.parametrize(("stored", "envelope"), [(None, 0), (0, 0), (3, 3), (9, 9)])
def test_export_preserves_actual_model_only_in_the_envelope(
tmp_path: Path, stored: int | None, envelope: int,
@@ -398,6 +473,29 @@ def test_space_merge_remaps_internal_marker_light_links(tmp_path: Path) -> None:
assert details["dropped_marker_links"] == 0
def test_issue_90_space_merge_remaps_internal_badge_marker_link(tmp_path: Path) -> None:
document = _document(tmp_path, "space")
markers = document["payload"]["config"]["markers"]
markers[0]["name"] = "Controller"
markers[0]["value_badge"] = {
"enabled": True,
"source": {"kind": "derived_marker_state", "ref": "marker:dumb"},
"position": "bottom",
}
markers.append({
"id": "dumb", "binding": "virtual", "space": "ground",
"room_id": "living", "name": "Dumb lamp", "is_light": True,
})
merged, _layout, details = build_space_merge(
document, {"spaces": [], "markers": [], "settings": {}}, {}, "skip",
)
imported = [m for m in merged["markers"] if m.get("space") == details["space_id"]]
by_name = {m.get("name"): m for m in imported}
assert by_name["Controller"]["value_badge"]["source"]["ref"] == \
"marker:" + by_name["Dumb lamp"]["id"]
assert details["dropped_marker_links"] == 0
def test_space_export_drops_marker_links_outside_selection(tmp_path: Path) -> None:
config = _config()
config["markers"][0]["controls"] = ["marker:outside", "light.keep"]
@@ -415,6 +513,27 @@ def test_space_export_drops_marker_links_outside_selection(tmp_path: Path) -> No
assert document["transfer"]["dropped_marker_links"] == 1
def test_issue_90_space_export_disables_badge_link_outside_selection(tmp_path: Path) -> None:
config = _config()
config["markers"][0]["value_badge"] = {
"enabled": True,
"source": {"kind": "derived_marker_state", "ref": "marker:outside"},
"position": "left",
}
config["markers"].append({
"id": "outside", "binding": "virtual", "space": "other",
"name": "Other lamp", "is_light": True,
})
document, _ = create_export(
SimpleNamespace(instance_id="instance-a"), {"config": config},
{"layout": {"lamp": {"s": "ground", "x": 0.5, "y": 0.5}}},
kind="space", space_id="ground", card_version="1.61.0", config_root=tmp_path,
)
badge = document["payload"]["config"]["markers"][0]["value_badge"]
assert badge == {"enabled": False, "source": None, "position": "left"}
assert document["transfer"]["dropped_marker_links"] == 1
def test_space_merge_drops_link_when_target_is_virtualized_duplicate(tmp_path: Path) -> None:
document = _document(tmp_path, "space")
controller = document["payload"]["config"]["markers"][0]
@@ -503,8 +622,8 @@ async def _setup(hass: HomeAssistant) -> MockConfigEntry:
class _Connection:
def __init__(self) -> None:
self.user = SimpleNamespace(id="review-owner", is_admin=True)
def __init__(self, *, is_admin: bool = True) -> None:
self.user = SimpleNamespace(id="review-owner", is_admin=is_admin)
self.result: dict[str, Any] | None = None
self.error: tuple[str, str] | None = None
@@ -591,6 +710,65 @@ async def test_export_and_revalidate_ws_endpoints_use_server_owned_state(
assert refreshed.result["expected_layout_rev"] == response["expected_layout_rev"]
@pytest.mark.parametrize(("endpoint", "message"), [
(wsapi.ws_export_create, {
"id": 50, "type": "houseplan/export/create", "kind": "full", "card_version": "review",
}),
(wsapi.ws_import_revalidate, {
"id": 51, "type": "houseplan/import/revalidate", "token": "opaque",
"duplicate_policy": "skip",
}),
(wsapi.ws_import_apply, {
"id": 52, "type": "houseplan/import/apply", "token": "opaque",
"expected_config_rev": 0, "expected_layout_rev": 0,
"confirm_missing_content": False,
}),
])
async def test_portable_backup_ws_endpoints_reject_non_editors(
hass: HomeAssistant, endpoint, message: dict[str, Any],
) -> None:
connection = _Connection(is_admin=False)
await endpoint.__wrapped__(hass, connection, message)
assert connection.result is None
assert connection.error and connection.error[0] == "unauthorized"
class _ImportPreviewRequest:
def __init__(self, hass: HomeAssistant, *, is_admin: bool) -> None:
self.app = {KEY_HASS: hass}
self.query: dict[str, str] = {}
self.content_length = 0
self._user = SimpleNamespace(id="http-review", is_admin=is_admin)
def get(self, key: str, default=None):
return self._user if key == "hass_user" else default
async def test_import_preview_http_rejects_non_editor_and_reports_not_ready(
hass: HomeAssistant,
) -> None:
view = HouseplanImportPreviewView()
forbidden = await view.post(_ImportPreviewRequest(hass, is_admin=False))
assert forbidden.status == 403
unavailable = await view.post(_ImportPreviewRequest(hass, is_admin=True))
assert unavailable.status == 503
async def test_import_apply_conflict_preserves_state_and_preview_token(
hass: HomeAssistant, tmp_path: Path,
) -> None:
await _setup(hass)
rt, response, _document_value = await _candidate(hass, tmp_path)
current = await rt.config_store.async_load()
await rt.config_store.async_save({**current, "rev": 2})
connection = await _apply(hass, response)
assert connection.result is None
assert connection.error and connection.error[0] == "conflict"
assert response["token"] in rt.import_previews
assert (await rt.config_store.async_load())["config"]["spaces"][0]["title"] == "Ground"
assert (await rt.store.async_load())["layout"]["lamp"]["x"] == 0.1
async def test_apply_confirmed_detach_does_not_collect_files_and_commits_pair(
hass: HomeAssistant, tmp_path: Path, monkeypatch,
) -> None:
+51
View File
@@ -1750,3 +1750,54 @@ def test_light_entity_schema_is_literal_and_domain_bounded():
v.CONFIG_SCHEMA({"spaces": [], "markers": [
{"id": "lamp", "binding": "virtual", "light_entity": invalid},
]})
def test_issue_90_value_badge_validation_is_strict_only_when_changed():
valid = {
"enabled": True,
"source": {"kind": "entity_attribute", "entity_id": "climate.room",
"attribute": "current_temperature", "future_source": 1},
"position": "top",
"future_badge": {"kept": True},
}
config = {"markers": [{"id": "m1", "binding": "entity:climate.room",
"value_badge": valid}]}
v.validate_marker_value_badges(config, {"markers": []})
stored = v.MARKER_SCHEMA(config["markers"][0])
assert stored["value_badge"]["future_badge"] == {"kept": True}
assert stored["value_badge"]["source"]["future_source"] == 1
broken = {"markers": [{"id": "m1", "binding": "virtual", "value_badge": {
"enabled": True, "source": None, "position": "right",
}}]}
v.validate_marker_value_badges(broken, broken)
with pytest.raises(v.MarkerControlError) as missing:
v.validate_marker_value_badges(broken, {"markers": []})
assert missing.value.code == "value_badge_source_required"
invalid_attr = {"markers": [{"id": "m1", "binding": "entity:sensor.x",
"value_badge": {
"enabled": True,
"source": {"kind": "entity_attribute", "entity_id": "sensor.x",
"attribute": "entity_picture"},
"position": "right",
}}]}
with pytest.raises(v.MarkerControlError) as attribute:
v.validate_marker_value_badges(invalid_attr, {"markers": []})
assert attribute.value.code == "invalid_value_badge_attribute"
def test_issue_90_marker_value_badge_reference_uses_canonical_ref():
config = {"markers": [
{"id": "lamp", "binding": "virtual", "is_light": True},
{"id": "controller", "binding": "entity:switch.wall", "value_badge": {
"enabled": True,
"source": {"kind": "derived_marker_state", "ref": "marker:lamp"},
"position": "bottom",
}},
]}
v.validate_marker_value_badges(config, {"markers": []})
config["markers"][1]["value_badge"]["source"]["ref"] = "lamp"
with pytest.raises(v.MarkerControlError) as invalid:
v.validate_marker_value_badges(config, {"markers": []})
assert invalid.value.code == "invalid_value_badge_source"