diff --git a/tests_backend/test_ha_radar.py b/tests_backend/test_ha_radar.py index 29016cc9..a7bd0d0a 100644 --- a/tests_backend/test_ha_radar.py +++ b/tests_backend/test_ha_radar.py @@ -251,3 +251,127 @@ async def test_teardown_releases_frames_and_subscriptions( assert coordinator.closed is True assert coordinator.frames_for_space("floor") == [] assert coordinator._unsub_sources == [] + + +@pytest.mark.asyncio +async def test_presence_only_profile_reports_without_geometry( + hass: HomeAssistant, +) -> None: + document = _stored("presence_v1") + radar = document["config"]["markers"][0]["radar"] + radar["sources"] = {"occupancy_entity": "binary_sensor.radar_presence"} + hass.states.async_set("binary_sensor.radar_presence", "on") + + coordinator = await _coordinator(hass, document) + frame = coordinator.frame("radar") + assert frame is not None + assert frame["health"] == "ok" + assert frame["reported_presence"] is True + assert frame["complete"] is True + assert frame["expires_at"] is None + assert coordinator.inspect("radar")["capabilities"] == ["reported_presence"] + coordinator.teardown() + + +@pytest.mark.asyncio +async def test_zone_profile_normalizes_occupancy_and_count( + hass: HomeAssistant, +) -> None: + document = _stored("zones_v1") + radar = document["config"]["markers"][0]["radar"] + radar["sources"] = {"zones": [ + {"id": "desk", "entity_id": "binary_sensor.desk", "kind": "occupancy"}, + {"id": "people", "entity_id": "sensor.people", "kind": "count"}, + ]} + hass.states.async_set("binary_sensor.desk", "on") + hass.states.async_set("sensor.people", "2") + + coordinator = await _coordinator(hass, document) + frame = coordinator.frame("radar") + assert frame is not None + assert frame["health"] == "ok" + assert frame["complete"] is True + assert frame["zones"] == [ + {"id": "desk", "state": True}, {"id": "people", "state": 2.0}, + ] + assert coordinator.inspect("radar")["capabilities"] == ["zone_state"] + coordinator.teardown() + + +@pytest.mark.asyncio +async def test_range_profile_clips_arc_to_owner_room( + hass: HomeAssistant, +) -> None: + document = _stored("range_v1") + radar = document["config"]["markers"][0]["radar"] + radar["sources"] = {"ranges": [{ + "id": "distance", "entity_id": "sensor.radar_distance", "unit": "m", + "presence_entity": "binary_sensor.radar_presence", + }]} + hass.states.async_set("binary_sensor.radar_presence", "on") + hass.states.async_set("sensor.radar_distance", "2") + + coordinator = await _coordinator(hass, document) + frame = coordinator.frame("radar") + assert frame is not None + assert frame["health"] == "ok" + assert frame["complete"] is True + assert frame["ranges"][0]["radius"] == pytest.approx(200 / 1200) + assert frame["ranges"][0]["segments"] + assert coordinator.inspect("radar")["capabilities"] == ["range"] + coordinator.teardown() + + +@pytest.mark.asyncio +async def test_polar_profile_projects_explicit_bearing( + hass: HomeAssistant, +) -> None: + document = _stored("polar_v1") + radar = document["config"]["markers"][0]["radar"] + radar["sources"] = {"slots": [{ + "id": "target_1", "distance_entity": "sensor.radar_distance", + "angle_entity": "sensor.radar_angle", "unit": "cm", + "angle_unit": "degrees", "angle_zero": "forward", + "angle_clockwise": True, + }]} + hass.states.async_set("sensor.radar_distance", "100") + hass.states.async_set("sensor.radar_angle", "90") + + coordinator = await _coordinator(hass, document) + frame = coordinator.frame("radar") + assert frame is not None + assert frame["health"] == "ok" + assert frame["targets"][0]["x"] == pytest.approx(.5 + 100 / 1200) + assert frame["targets"][0]["y"] == pytest.approx(.5) + coordinator.teardown() + + +@pytest.mark.asyncio +async def test_coordinator_query_and_listener_lifecycle( + hass: HomeAssistant, +) -> None: + hass.states.async_set("binary_sensor.radar_presence", "off") + coordinator = await _coordinator(hass, _stored()) + public_events = [] + internal_events = [] + remove_public = coordinator.add_listener( + lambda marker_id, frame: public_events.append((marker_id, frame)), + ) + remove_internal = coordinator.add_internal_listener( + lambda marker_id, frame: internal_events.append((marker_id, frame)), + ) + assert public_events[0][0] == "radar" + assert coordinator.has_space("floor") is True + assert coordinator.has_space("missing") is False + assert coordinator.space_for_marker("missing") is None + assert coordinator.marker_config("radar")["id"] == "radar" + assert coordinator.marker_config("missing") is None + assert coordinator.source_ids("missing") == set() + assert coordinator.inspect("missing")["health"] == "not_configured" + + coordinator._publish_all(force=True) + assert internal_events[-1][0] == "radar" + remove_public() + remove_internal() + assert coordinator._unsub_tick is None + coordinator.teardown() diff --git a/tests_backend/test_ha_radar_websocket.py b/tests_backend/test_ha_radar_websocket.py new file mode 100644 index 00000000..c8913724 --- /dev/null +++ b/tests_backend/test_ha_radar_websocket.py @@ -0,0 +1,317 @@ +"""Permission, lifecycle and back-pressure witnesses for radar WebSockets.""" +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from custom_components.houseplan import radar_websocket as radar_ws +from custom_components.houseplan.radar import RadarCoordinator + + +class _Permissions: + def __init__(self, allowed: bool = True) -> None: + self.allowed = allowed + + def check_entity(self, _entity_id: str, _policy: str) -> bool: + return self.allowed + + +class _Connection: + def __init__(self, *, allowed: bool = True) -> None: + self.user = SimpleNamespace(id="user-1", permissions=_Permissions(allowed)) + self.subscriptions: dict[int, object] = {} + self.results: list[tuple[int, object]] = [] + self.errors: list[tuple[int, str, str]] = [] + self.events: list[tuple[int, object]] = [] + + def send_result(self, msg_id: int, result: object) -> None: + self.results.append((msg_id, result)) + + def send_error(self, msg_id: int, code: str, message: str) -> None: + self.errors.append((msg_id, code, message)) + + def send_event(self, msg_id: int, event: object) -> None: + self.events.append((msg_id, event)) + + +class _Coordinator: + def __init__(self) -> None: + self.closed = False + self.server_session_id = "session-1" + self.config_rev = 7 + self.config = {"markers": []} + self.radars = {"radar": {"id": "radar", "radar": {"version": 1}}} + self.listener = None + self.listener_removed = False + self.external_cleanup = None + + def has_space(self, space_id: str) -> bool: + return space_id == "floor" + + def source_ids(self, marker_id: str) -> set[str]: + return {"sensor.x"} if marker_id == "radar" else set() + + def space_for_marker(self, marker_id: str) -> str | None: + return "floor" if marker_id == "radar" else "other" + + def frames_for_space(self, _space_id: str) -> list[dict]: + return [{"marker_id": "radar", "seq": 3, "targets": []}] + + @staticmethod + def public_frame(frame: dict) -> dict: + return {**frame, "public": True} + + def add_listener(self, listener, *, publish_initial: bool = True): + assert publish_initial is False + self.listener = listener + + def remove() -> None: + self.listener_removed = True + + return remove + + def inspect(self, marker_id: str) -> dict: + return {"marker_id": marker_id, "health": "ok"} + + def inspect_draft(self, marker: dict, radar: dict) -> dict: + return {"marker_id": marker["id"], "profile": radar.get("profile")} + + def marker_config(self, marker_id: str) -> dict | None: + return self.radars.get(marker_id) + + def add_external_cleanup(self, cleanup): + self.external_cleanup = cleanup + + def unregister() -> None: + self.external_cleanup = None + + return unregister + + +@pytest.fixture(autouse=True) +def _clear_radar_ws_state(): + radar_ws._INSPECT_CALLS.clear() + radar_ws._ACTIVE_SUBSCRIPTIONS.clear() + radar_ws._ACTIVE_SETUP.clear() + yield + radar_ws._INSPECT_CALLS.clear() + radar_ws._ACTIVE_SUBSCRIPTIONS.clear() + radar_ws._ACTIVE_SETUP.clear() + + +def test_registers_all_radar_commands(monkeypatch, hass) -> None: + registered = [] + monkeypatch.setattr(radar_ws.websocket_api, "async_register_command", + lambda _hass, command: registered.append(command)) + radar_ws.async_register(hass) + assert registered == [ + radar_ws.ws_radar_subscribe, + radar_ws.ws_radar_setup_inspect, + radar_ws.ws_radar_setup_subscribe, + ] + + +def test_coordinator_lookup_and_read_permissions(monkeypatch, hass) -> None: + connection = _Connection() + monkeypatch.setattr(radar_ws, "get_data", lambda _hass: None) + assert radar_ws._coordinator(hass, connection, 1) is None + assert connection.errors[-1][1] == "not_ready" + + coordinator = RadarCoordinator(hass, SimpleNamespace()) + monkeypatch.setattr( + radar_ws, "get_data", lambda _hass: SimpleNamespace(radar_coordinator=coordinator), + ) + assert radar_ws._coordinator(hass, connection, 2) is coordinator + coordinator.closed = True + assert radar_ws._coordinator(hass, connection, 3) is None + assert radar_ws._can_read(connection, {"sensor.x"}) is True + connection.user.permissions.allowed = False + assert radar_ws._can_read(connection, {"sensor.x"}) is False + connection.user.permissions = None + assert radar_ws._can_read(connection, set()) is False + + +def test_rate_limit_prunes_old_calls() -> None: + now = radar_ws.time.monotonic() + radar_ws._INSPECT_CALLS["user"].extend([now] * 10) + assert radar_ws._rate_limit("user") is False + radar_ws._INSPECT_CALLS["user"].clear() + radar_ws._INSPECT_CALLS["user"].append(now - 61) + assert radar_ws._rate_limit("user") is True + + +def test_live_subscribe_publishes_filters_and_cleans_up(monkeypatch, hass) -> None: + coordinator = _Coordinator() + connection = _Connection() + monkeypatch.setattr(radar_ws, "_coordinator", lambda *_args: coordinator) + radar_ws.ws_radar_subscribe(hass, connection, {"id": 1, "space_id": "floor"}) + + assert connection.results[-1][1] == {"server_session_id": "session-1", "config_rev": 7} + assert connection.events[-1][1]["public"] is True + assert radar_ws._ACTIVE_SUBSCRIPTIONS["user-1"] == 1 + assert coordinator.listener is not None + coordinator.listener("other", {"seq": 4}) + assert len(connection.events) == 1 + connection.user.permissions.allowed = False + coordinator.listener("radar", {"seq": 5}) + assert connection.events[-1][1]["health"] == "restricted" + + connection.subscriptions[1]() + assert coordinator.listener_removed is True + assert "user-1" not in radar_ws._ACTIVE_SUBSCRIPTIONS + + +def test_live_subscribe_rejects_unknown_space_and_connection_limit(monkeypatch, hass) -> None: + coordinator = _Coordinator() + connection = _Connection() + monkeypatch.setattr(radar_ws, "_coordinator", lambda *_args: coordinator) + radar_ws.ws_radar_subscribe(hass, connection, {"id": 1, "space_id": "missing"}) + assert connection.errors[-1][1] == "invalid_selection" + radar_ws._ACTIVE_SUBSCRIPTIONS["user-1"] = 4 + radar_ws.ws_radar_subscribe(hass, connection, {"id": 2, "space_id": "floor"}) + assert connection.errors[-1][1] == "rate_limited" + + +def test_draft_validation_size_permission_and_success(monkeypatch) -> None: + coordinator = _Coordinator() + connection = _Connection() + marker = {"id": "radar"} + radar = {"profile": "cartesian_v1"} + monkeypatch.setattr( + radar_ws, "validate_radar_draft", lambda *_args: (marker, {"sensor.x"}), + ) + message = {"id": 1, "marker_id": "radar", "draft_sources": {"radar": radar}} + assert radar_ws._validated_draft(coordinator, message, connection) == ( + marker, radar, {"sensor.x"}, + ) + connection.user.permissions.allowed = False + assert radar_ws._validated_draft(coordinator, message, connection) is None + assert connection.errors[-1][1] == "source_restricted" + huge = {"id": 2, "marker_id": "radar", "draft_sources": {"x": "x" * 66000}} + assert radar_ws._validated_draft(coordinator, huge, connection) is None + assert connection.errors[-1][1] == "invalid_selection" + + +def test_draft_validation_reports_stable_radar_error(monkeypatch) -> None: + coordinator = _Coordinator() + connection = _Connection() + + def reject(*_args): + raise radar_ws.RadarValidationError("bad") + + monkeypatch.setattr(radar_ws, "validate_radar_draft", reject) + assert radar_ws._validated_draft( + coordinator, + {"id": 1, "marker_id": "radar", "draft_sources": {"radar": {}}}, + connection, + ) is None + assert connection.errors[-1][1] == "invalid_radar" + + +def test_setup_inspect_permissions_limits_saved_and_draft(monkeypatch, hass) -> None: + coordinator = _Coordinator() + connection = _Connection() + monkeypatch.setattr(radar_ws, "_coordinator", lambda *_args: coordinator) + monkeypatch.setattr(radar_ws, "may_write", lambda *_args: False) + radar_ws.ws_radar_setup_inspect(hass, connection, {"id": 1, "marker_id": "radar"}) + assert connection.errors[-1][1] == "unauthorized" + + monkeypatch.setattr(radar_ws, "may_write", lambda *_args: True) + connection.user.permissions.allowed = False + radar_ws.ws_radar_setup_inspect(hass, connection, {"id": 2, "marker_id": "radar"}) + assert connection.errors[-1][1] == "source_restricted" + connection.user.permissions.allowed = True + radar_ws.ws_radar_setup_inspect(hass, connection, {"id": 3, "marker_id": "radar"}) + assert connection.results[-1][1]["health"] == "ok" + + monkeypatch.setattr( + radar_ws, "_validated_draft", + lambda *_args: ({"id": "radar"}, {"profile": "range_v1"}, set()), + ) + radar_ws.ws_radar_setup_inspect( + hass, connection, + {"id": 4, "marker_id": "radar", "draft_sources": {}}, + ) + assert connection.results[-1][1]["profile"] == "range_v1" + monkeypatch.setattr(radar_ws, "_rate_limit", lambda _user_id: False) + radar_ws.ws_radar_setup_inspect(hass, connection, {"id": 5, "marker_id": "radar"}) + assert connection.errors[-1][1] == "rate_limited" + + +def test_setup_subscribe_guards_and_missing_marker(monkeypatch, hass) -> None: + coordinator = _Coordinator() + connection = _Connection() + monkeypatch.setattr(radar_ws, "_coordinator", lambda *_args: coordinator) + monkeypatch.setattr(radar_ws, "may_write", lambda *_args: False) + base = {"marker_id": "radar", "expected_config_rev": 7} + radar_ws.ws_radar_setup_subscribe(hass, connection, {"id": 1, **base}) + assert connection.errors[-1][1] == "unauthorized" + + monkeypatch.setattr(radar_ws, "may_write", lambda *_args: True) + radar_ws.ws_radar_setup_subscribe( + hass, connection, {"id": 2, **base, "expected_config_rev": 6}, + ) + assert connection.errors[-1][1] == "conflict" + radar_ws._ACTIVE_SETUP.add(("user-1", "radar")) + radar_ws.ws_radar_setup_subscribe(hass, connection, {"id": 3, **base}) + assert connection.errors[-1][1] == "rate_limited" + radar_ws._ACTIVE_SETUP.clear() + coordinator.radars.clear() + radar_ws.ws_radar_setup_subscribe(hass, connection, {"id": 4, **base}) + assert connection.errors[-1][1] == "invalid_selection" + + +@pytest.mark.asyncio +async def test_setup_subscription_coalesces_restricts_and_removes(monkeypatch, hass) -> None: + coordinator = _Coordinator() + connection = _Connection() + callbacks = [] + cleanups = [] + monkeypatch.setattr(radar_ws, "_coordinator", lambda *_args: coordinator) + monkeypatch.setattr(radar_ws, "may_write", lambda *_args: True) + + def track(_hass, _ids, callback): + callbacks.append(callback) + + def cleanup() -> None: + cleanups.append(True) + + return cleanup + + monkeypatch.setattr(radar_ws, "async_track_state_report_event", track) + monkeypatch.setattr(radar_ws, "async_track_state_change_event", track) + message = {"id": 9, "marker_id": "radar", "expected_config_rev": 7} + radar_ws.ws_radar_setup_subscribe(hass, connection, message) + assert connection.results + assert connection.events[-1][1]["marker_id"] == "radar" + assert ("user-1", "radar") in radar_ws._ACTIVE_SETUP + + callbacks[0](None) + callbacks[1](None) + connection.user.permissions.allowed = False + await asyncio.sleep(1 / radar_ws.MAX_FRAME_HZ + .05) + assert connection.events[-1][1]["health"] == "restricted" + + connection.subscriptions[9]() + connection.subscriptions[9]() + assert len(cleanups) == 2 + assert ("user-1", "radar") not in radar_ws._ACTIVE_SETUP + assert coordinator.external_cleanup is None + + +def test_setup_subscribe_draft_failure_and_source_restriction(monkeypatch, hass) -> None: + coordinator = _Coordinator() + connection = _Connection(allowed=False) + monkeypatch.setattr(radar_ws, "_coordinator", lambda *_args: coordinator) + monkeypatch.setattr(radar_ws, "may_write", lambda *_args: True) + base = {"marker_id": "radar", "expected_config_rev": 7} + radar_ws.ws_radar_setup_subscribe(hass, connection, {"id": 1, **base}) + assert connection.errors[-1][1] == "source_restricted" + + monkeypatch.setattr(radar_ws, "_validated_draft", lambda *_args: None) + radar_ws.ws_radar_setup_subscribe( + hass, connection, {"id": 2, **base, "draft_sources": {}}, + ) + assert 2 not in connection.subscriptions diff --git a/tests_backend/test_radar_validation.py b/tests_backend/test_radar_validation.py index 34e2ce11..76b321d1 100644 --- a/tests_backend/test_radar_validation.py +++ b/tests_backend/test_radar_validation.py @@ -6,7 +6,11 @@ import copy import pytest import voluptuous as vol -from custom_components.houseplan.radar_validation import validate_marker_radars +from custom_components.houseplan.radar_validation import ( + radar_source_entity_ids, + validate_marker_radars, + validate_radar_draft, +) def _config(): @@ -105,3 +109,174 @@ def test_stage2_zone_polygon_and_stage3_cross_space_group_fail_closed(): }]} with pytest.raises(vol.Invalid, match="zero area"): validate_marker_radars(config, validate_all=True) + + +@pytest.mark.parametrize("profile", [ + "esphome_ld2450_v1", "cartesian_v1", "polar_v1", "range_v1", + "zones_v1", "presence_v1", +]) +def test_all_stage1_profiles_have_a_valid_explicit_source_contract(profile): + config = _config() + radar = config["markers"][0]["radar"] + radar["profile"] = profile + if profile == "esphome_ld2450_v1": + radar["sources"]["slots"][0]["unit"] = "mm" + elif profile == "polar_v1": + radar["sources"] = {"slots": [{ + "id": "one", "distance_entity": "sensor.distance", + "angle_entity": "sensor.angle", "unit": "cm", + "angle_unit": "degrees", "angle_zero": "forward", + "angle_clockwise": True, + }]} + elif profile == "range_v1": + radar["sources"] = {"ranges": [{ + "id": "range", "entity_id": "sensor.distance", "unit": "m", + "presence_entity": "binary_sensor.presence", + }]} + elif profile == "zones_v1": + radar["sources"] = {"zones": [ + {"id": "occupied", "entity_id": "binary_sensor.zone", "kind": "occupancy"}, + {"id": "count", "entity_id": "sensor.zone_count", "kind": "count"}, + ]} + elif profile == "presence_v1": + radar["sources"] = {"occupancy_entity": "binary_sensor.presence"} + validate_marker_radars(config, validate_all=True) + + +def test_stage2_metadata_and_source_inventory_are_validated_together(): + config = _config() + radar = config["markers"][0]["radar"] + radar["sources"].update({ + "occupancy_entity": "binary_sensor.presence", + "count_entity": "sensor.count", + "availability_entity": "binary_sensor.available", + }) + radar["sources"]["slots"][0]["presence_entity"] = "binary_sensor.slot" + radar["zones"] = { + "local": [{ + "id": "desk", "name": "Desk", "poly": [ + {"x": 0, "y": 0}, {"x": 100, "y": 0}, + {"x": 100, "y": 100}, {"x": 0, "y": 100}, + ], + "state": {"kind": "occupancy", "entity_id": "binary_sensor.desk"}, + }], + "hardware": { + "adapter": "esphome_ld2450_numbers_v1", + "mode_entity": "select.zone_mode", + "slots": [{ + "slot": index, + "x1_entity": f"number.zone_{index}_x1", + "y1_entity": f"number.zone_{index}_y1", + "x2_entity": f"number.zone_{index}_x2", + "y2_entity": f"number.zone_{index}_y2", + } for index in range(1, 4)], + }, + } + radar["reflectors"] = [{ + "id": "mirror", "name": "Mirror", "enabled": True, + "a": {"x": 0, "y": 0}, "b": {"x": 100, "y": 0}, + }] + radar["allowed_room_ids"] = ["living"] + validate_marker_radars(config, validate_all=True) + ids = radar_source_entity_ids(radar) + assert {"sensor.x", "sensor.y", "binary_sensor.desk", "select.zone_mode", + "number.zone_3_y2"} <= ids + assert radar_source_entity_ids(None) == set() + assert radar_source_entity_ids({"sources": []}) == set() + + +def test_valid_fusion_and_room_output_settings(): + config = _config() + second = copy.deepcopy(config["markers"][0]) + second["id"] = "radar-two" + second["binding"] = "device:def" + second["radar"]["mount"]["installation_id"] = "install-2" + second["radar"]["sources"]["slots"][0].update( + id="two", x_entity="sensor.x2", y_entity="sensor.y2", + ) + config["markers"].append(second) + config["settings"]["radar"] = { + "version": 1, "show_live": False, + "fusion_groups": [{ + "id": "living-pair", "enabled": True, "space_id": "floor", + "marker_ids": ["radar", "radar-two"], + }], + "room_outputs": [{ + "id": "living-output", "space_id": "floor", "room_id": "living", + "presence": True, "estimated_count": False, + }], + } + validate_marker_radars(config, validate_all=True) + + +@pytest.mark.parametrize("mutation", [ + lambda r: r.update(enabled="yes"), + lambda r: r.update(show_live="yes"), + lambda r: r.update(profile="magic_v1"), + lambda r: r.update(room_id="missing"), + lambda r: r["mount"].update(installation_id="bad id"), + lambda r: r["mount"].update(heading_deg=360), + lambda r: r["mount"].update(range_cm=0), + lambda r: r["mount"].update(fov_deg=361), + lambda r: r["calibration"].update(method="guess"), + lambda r: r["calibration"].update(mirror=1), + lambda r: r["calibration"].update(refs=[{}]), + lambda r: r["calibration"].update(rms_cm=31), + lambda r: r.update(allowed_room_ids=["living", "living"]), +]) +def test_invalid_core_mount_and_calibration_contracts_are_rejected(mutation): + config = _config() + mutation(config["markers"][0]["radar"]) + with pytest.raises(vol.Invalid): + validate_marker_radars(config, validate_all=True) + + +@pytest.mark.parametrize("sources", [ + {}, + {"slots": [{"id": "one", "x_entity": "sensor.x", "y_entity": "sensor.y", + "unit": "mm", "x_sign": 0}]}, + {"slots": [{"id": "one", "x_entity": "sensor.x", "y_entity": "sensor.y", + "unit": "mm", "swap_xy": "yes"}]}, + {"slots": [{"id": "one", "x_entity": "sensor.x", "y_entity": "sensor.y", + "unit": "mm", "presence_entity": "sensor.wrong"}]}, +]) +def test_invalid_cartesian_sources_are_rejected(sources): + config = _config() + config["markers"][0]["radar"]["sources"] = sources + with pytest.raises(vol.Invalid): + validate_marker_radars(config, validate_all=True) + + +@pytest.mark.parametrize("change", [ + {"unit": "yards"}, {"angle_unit": "turns"}, {"angle_zero": "north"}, + {"angle_clockwise": 1}, {"angle_entity": "sensor.distance"}, +]) +def test_invalid_polar_sources_are_rejected(change): + config = _config() + radar = config["markers"][0]["radar"] + radar["profile"] = "polar_v1" + slot = { + "id": "one", "distance_entity": "sensor.distance", "angle_entity": "sensor.angle", + "unit": "cm", "angle_unit": "degrees", "angle_zero": "forward", + "angle_clockwise": True, + } + slot.update(change) + radar["sources"] = {"slots": [slot]} + with pytest.raises(vol.Invalid): + validate_marker_radars(config, validate_all=True) + + +def test_draft_rejects_missing_removed_and_virtual_marker(): + config = _config() + radar = config["markers"][0]["radar"] + candidate, source_ids = validate_radar_draft(config, "radar", radar) + assert candidate["id"] == "radar" and source_ids == {"sensor.x", "sensor.y"} + for marker_id in ("missing",): + with pytest.raises(vol.Invalid, match="setup marker"): + validate_radar_draft(config, marker_id, radar) + config["markers"][0]["removed"] = True + with pytest.raises(vol.Invalid, match="setup marker"): + validate_radar_draft(config, "radar", radar) + config["markers"][0].update(removed=False, binding="virtual") + with pytest.raises(vol.Invalid, match="setup marker"): + validate_radar_draft(config, "radar", radar)