mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Release v1.62.0-beta.2 candidate
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user