"""Tests for Home Assistant MQTT auto-discovery. The end-to-end entity appearance, retained-config survival across HA restart, and availability flips need a live broker + HA. Here we lock down the parts that DON'T: the discovery config payloads, publish_all / remove_all topic sets, the MQTTSource field round-trip, and the manager's state fan-out. """ from __future__ import annotations from datetime import datetime, timezone import pytest from ledgrab.core.mqtt.ha_discovery import HADiscoveryPublisher from ledgrab.core.mqtt.mqtt_manager import MQTTManager from ledgrab.storage.mqtt_source import MQTTSource class _FakeRuntime: def __init__(self) -> None: self.published: list[tuple[str, str, bool]] = [] self.states: list[tuple[str, str]] = [] async def publish(self, topic: str, payload: str, retain: bool = False, qos: int = 0) -> None: self.published.append((topic, payload, retain)) async def publish_automation_state(self, automation_id: str, action: str) -> None: self.states.append((automation_id, action)) class _Auto: def __init__(self, id: str, name: str) -> None: self.id = id self.name = name class _FakeAutomationStore: def __init__(self, autos) -> None: self._autos = autos def get_all(self): return self._autos def _source(**kw) -> MQTTSource: now = datetime.now(timezone.utc) defaults = dict(id="mqs_1", name="Broker", created_at=now, updated_at=now) defaults.update(kw) return MQTTSource(**defaults) def _publisher(autos=None, **src_kw): runtime = _FakeRuntime() source = _source(**src_kw) store = _FakeAutomationStore(autos if autos is not None else [_Auto("auto_1", "Movie Night")]) return HADiscoveryPublisher(runtime, source, store, version="9.9.9"), runtime class TestConfigBuilders: def test_connectivity_config_shape(self): pub, _ = _publisher(base_topic="ledgrab") topic, payload = pub.build_connectivity_config() assert topic == "homeassistant/binary_sensor/ledgrab_mqs_1/connectivity/config" assert payload["device_class"] == "connectivity" assert payload["state_topic"] == "ledgrab/status" assert payload["unique_id"] == "ledgrab_mqs_1_connectivity" assert payload["device"]["identifiers"] == ["ledgrab_mqs_1"] assert payload["device"]["sw_version"] == "9.9.9" def test_automation_config_shape(self): pub, _ = _publisher(base_topic="lg") topic, payload = pub.build_automation_config(_Auto("auto_7", "Night")) assert topic == "homeassistant/binary_sensor/ledgrab_mqs_1/automation_auto_7/config" assert payload["state_topic"] == "lg/automation/auto_7/state" assert payload["value_template"] == "{{ value_json.action }}" assert payload["payload_on"] == "active" and payload["payload_off"] == "inactive" assert payload["availability_topic"] == "lg/status" assert payload["name"] == "Night" def test_custom_discovery_prefix(self): pub, _ = _publisher(discovery_prefix="ha") topic, _ = pub.build_connectivity_config() assert topic.startswith("ha/binary_sensor/") class TestPublishRemove: @pytest.mark.asyncio async def test_publish_all_publishes_retained_configs_and_state(self): pub, runtime = _publisher(autos=[_Auto("a1", "One"), _Auto("a2", "Two")]) await pub.publish_all() config_topics = [t for (t, p, r) in runtime.published if t.endswith("/config")] assert any("connectivity" in t for t in config_topics) assert sum("automation_" in t for t in config_topics) == 2 assert all(r for (_t, _p, r) in runtime.published) # all retained # Seeded initial states. assert ("a1", "inactive") in runtime.states @pytest.mark.asyncio async def test_remove_all_clears_with_empty_payload(self): pub, runtime = _publisher(autos=[_Auto("a1", "One")]) await pub.publish_all() runtime.published.clear() await pub.remove_all() # Every clear is an empty retained payload to a config topic. assert runtime.published assert all(p == "" and r for (_t, p, r) in runtime.published) assert all(t.endswith("/config") for (t, _p, _r) in runtime.published) class TestSourceRoundTrip: def test_fields_round_trip(self): s = _source(publish_ha_discovery=True, discovery_prefix="ha") back = MQTTSource.from_dict(s.to_dict()) assert back.publish_ha_discovery is True assert back.discovery_prefix == "ha" def test_defaults_when_absent(self): back = MQTTSource.from_dict({"id": "x", "name": "n", "created_at": "", "updated_at": ""}) assert back.publish_ha_discovery is False assert back.discovery_prefix == "homeassistant" class TestManagerStateFanout: @pytest.mark.asyncio async def test_publish_state_only_to_discovery_sources(self): mgr = MQTTManager(store=None, automation_store=None) runtime = _FakeRuntime() # Inject a fake runtime + mark it discovery-enabled. mgr._runtimes["mqs_1"] = (runtime, 1) mgr._discovery_sources.add("mqs_1") await mgr.publish_automation_state_all("auto_1", True) assert runtime.states == [("auto_1", "active")] await mgr.publish_automation_state_all("auto_1", False) assert runtime.states[-1] == ("auto_1", "inactive") @pytest.mark.asyncio async def test_no_discovery_sources_is_noop(self): mgr = MQTTManager(store=None, automation_store=None) # Should not raise with no discovery sources. await mgr.publish_automation_state_all("auto_1", True) if __name__ == "__main__": pytest.main([__file__, "-v"])