"""Unit tests for the protocol transport (androidtvremote2, Phase 2, t8). Fake client mirrors AndroidTVRemote's surface (callback registration, an ``async_connect`` that must run BEFORE ``keep_reconnecting``, a self-owned reconnect task that is a no-op until a session exists, sync ``disconnect``); no real gRPC / no Home Assistant. Also covers `volume_tuple` normalization against the real library's VolumeInfo TypedDict when the package is present (`uvx --with androidtvremote2 pytest`). """ import asyncio import sys import time import pytest from nsh.connection import ( FIELD_AUDIO_PLAYBACK, FIELD_AWAKE, FIELD_FOREGROUND, FIELD_IS_AVAILABLE, FIELD_IS_ON, FIELD_RUNNING_APPS, FIELD_SESSIONS, FIELD_VOLUME_INFO, ShieldConnectionManager, StateStore, volume_tuple, ) class InvalidAuth(Exception): # noqa: N818 - the library's own class name """The library's InvalidAuth, matched by NAME (the manager never imports it).""" class CannotConnect(Exception): # noqa: N818 - the library's own class name """The library's CannotConnect — a retryable transport failure.""" class ConnectionClosed(Exception): # noqa: N818 - the library's own class name """The library's ConnectionClosed — raised when nothing is connected.""" class FakeProtocolClient: """AndroidTVRemote-shaped fake: the library's ORDER, reproduced. t15: the real ``AndroidTVRemote`` keeps ``_remote_message_protocol = None`` until ``async_connect()`` succeeds, and ``keep_reconnecting()`` starts a loop whose condition is ``while self._remote_message_protocol:`` — so calling it alone starts a task that returns immediately and NOTHING ever connects (no push, and every send raises ConnectionClosed). The fake reproduces that, and records which of the two calls the manager made, so a regression back to keep_reconnecting-only fails a test instead of silently disabling the transport. """ def __init__(self, connect_error: Exception | None = None): self._remote_message_protocol = None # library attribute: None until connected self._connect_error = connect_error self.connect_calls = 0 self.keep_reconnecting_calls = 0 self.keys_sent: list = [] self.apps_launched: list = [] self.texts_sent: list = [] self._is_on_callbacks: list = [] self._is_available_callbacks: list = [] self._current_app_callbacks: list = [] self._volume_callbacks: list = [] self._invalid_auth_callback = None self._reconnect_task = None self.disconnect_calls = 0 # -- registration (mirrors the real API) -- def add_is_on_updated_callback(self, callback): self._is_on_callbacks.append(callback) def add_is_available_updated_callback(self, callback): self._is_available_callbacks.append(callback) def add_current_app_updated_callback(self, callback): self._current_app_callbacks.append(callback) def add_volume_info_updated_callback(self, callback): self._volume_callbacks.append(callback) # -- pushes (test helper: simulates device-side messages) -- def push_is_on(self, on): for cb in list(self._is_on_callbacks): cb(on) def push_is_available(self, available): for cb in list(self._is_available_callbacks): cb(available) def push_current_app(self, app_package): for cb in list(self._current_app_callbacks): cb(app_package) def push_volume_info(self, info): for cb in list(self._volume_callbacks): cb(info) # -- lifecycle -- def keep_reconnecting(self, invalid_auth_callback=None): """Own task like the real library — and, like it, a NO-OP until connected. The condition is the library's own: with no session the task runs once, finds nothing to maintain and returns. That is exactly why ``async_connect()`` has to come first. """ self.keep_reconnecting_calls += 1 self._invalid_auth_callback = invalid_auth_callback loop = asyncio.get_event_loop() self._reconnect_task = loop.create_task(self._reconnect_loop()) async def _reconnect_loop(self): while self._remote_message_protocol: # the library's own guard await asyncio.sleep(0.05) # -- connect (t15: the call the manager used to be missing entirely) -- async def async_connect(self): """Connect; raise ``self._connect_error`` when the test wants a failure.""" self.connect_calls += 1 if self._connect_error is not None: raise self._connect_error self._remote_message_protocol = object() # library sets its RemoteProtocol # -- sends (t15: the library raises when nothing is connected) -- def send_key_command(self, key, direction="SHORT"): if not self._remote_message_protocol: raise ConnectionClosed("Called send_key_command after disconnect") self.keys_sent.append((key, direction)) def send_launch_app_command(self, app_link_or_app_id): if not self._remote_message_protocol: raise ConnectionClosed("Called send_launch_app_command after disconnect") self.apps_launched.append(app_link_or_app_id) def send_text(self, text): if not self._remote_message_protocol: raise ConnectionClosed("Called send_text after disconnect") self.texts_sent.append(text) def fire_invalid_auth(self): if self._invalid_auth_callback is not None: self._invalid_auth_callback() def disconnect(self): self.disconnect_calls += 1 if self._reconnect_task is not None: self._reconnect_task.cancel() def _protocol_manager(client=None, **kwargs) -> ShieldConnectionManager: return ShieldConnectionManager(hass=None, entry=None, protocol=client, **kwargs) async def _drain_loop(): """Let call_soon_threadsafe callbacks scheduled via the running loop run.""" await asyncio.sleep(0.01) async def _connect(manager, client, tries: int = 40): """Wait for the manager's background connect task to make its attempt (t15).""" for _ in range(tries): if client.connect_calls: await _drain_loop() return await asyncio.sleep(0.005) raise AssertionError("the manager never attempted to connect the protocol") class TestPushCallbackWiring: async def test_is_on_and_availability_push_into_store(self): client = FakeProtocolClient() manager = _protocol_manager(client) notified = [] manager.register_listener(lambda: notified.append(True)) await manager._start_protocol() client.push_is_on(True) client.push_is_available(False) await _drain_loop() assert manager.is_on is True assert manager.is_available is False assert manager.store.get(FIELD_IS_ON) is True assert notified, "listeners pinged from protocol pushes" await manager.async_stop() async def test_current_app_push_stored_raw_package(self): client = FakeProtocolClient() manager = _protocol_manager(client) await manager._start_protocol() client.push_current_app("com.netflix.ninja") await _drain_loop() assert manager.current_app == "com.netflix.ninja" # name resolution deferred await manager.async_stop() async def test_volume_push_normalized_to_level_max_muted(self): client = FakeProtocolClient() manager = _protocol_manager(client) await manager._start_protocol() client.push_volume_info({"level": 7, "max": 15, "muted": True}) await _drain_loop() assert manager.volume_info == (7.0, 15.0, True) assert manager.store.get(FIELD_VOLUME_INFO)[2] is True await manager.async_stop() class TestInvalidAuthHook: async def test_invalid_auth_invokes_entry_callback(self): client = FakeProtocolClient() fired = [] manager = _protocol_manager( client, invalid_auth_callback=lambda: fired.append(True) ) await manager._start_protocol() await _connect(manager, client) # t15: a session first, then pushes/auth client.fire_invalid_auth() assert fired == [True], "reauth hook fired, not a retry storm (t9 delegates)" await manager.async_stop() async def test_invalid_auth_without_callback_is_safe(self): client = FakeProtocolClient() manager = _protocol_manager(client) await manager._start_protocol() await _connect(manager, client) client.fire_invalid_auth() # no callback registered — must not raise await manager.async_stop() def _fast_backoff(manager, delay: float = 0.01) -> None: """Make the connect-retry backoff fast for tests (t15), per manager.""" manager._jitter = lambda _delay: delay class TestInitialConnect: """t15: the transport must CONNECT, not merely be told to keep reconnecting. The shipped bug: `_start_protocol` called `keep_reconnecting()` and nothing in the tree ever called `async_connect()`. The library's reconnect task is `while self._remote_message_protocol:` — None until a first connect — so the task returned immediately: no session, no push, every send raising ConnectionClosed inside a swallowed handler, for every install. These tests pin the order (connect first) and the state it reports. """ async def test_connects_before_handing_over_to_keep_reconnecting(self): client = FakeProtocolClient() manager = _protocol_manager(client) await manager._start_protocol() await _connect(manager, client) assert client.connect_calls == 1 assert client.keep_reconnecting_calls == 1 assert client._remote_message_protocol is not None, "a session exists now" assert client._reconnect_task is not None, ( "keep_reconnecting only maintains a session; a task with no session " "would be the pre-t15 no-op" ) assert manager.protocol_state == "connected" assert manager.protocol_connected is True assert manager.protocol_ever_authenticated is True assert manager.protocol_last_error is None await manager.async_stop() async def test_keep_reconnecting_alone_would_stay_unconnected(self): """The fake reproduces the library: without async_connect nothing happens.""" client = FakeProtocolClient() client.keep_reconnecting(lambda: None) await _drain_loop() assert client._reconnect_task.done(), ( "the library's loop returns at once with no session — which is why " "keep_reconnecting() alone never connected anything" ) assert client.connect_calls == 0 async def test_network_failure_keeps_retrying_and_stays_never_connected(self): client = FakeProtocolClient(connect_error=CannotConnect("unreachable")) manager = _protocol_manager(client) _fast_backoff(manager) await manager._start_protocol() await _connect(manager, client) assert manager.protocol_state == "never_connected" assert manager.protocol_connected is False assert "CannotConnect" in (manager.protocol_last_error or "") assert client.keep_reconnecting_calls == 0, ( "nothing to maintain until a session exists" ) for _ in range(200): # the retry loop must keep trying if client.connect_calls >= 2: break await asyncio.sleep(0.005) assert client.connect_calls >= 2, "a network failure is retried with backoff" assert manager.protocol_ever_authenticated is False await manager.async_stop() async def test_auth_rejection_stops_and_runs_the_reauth_hook(self): client = FakeProtocolClient(connect_error=InvalidAuth("Need to pair")) fired = [] manager = _protocol_manager( client, invalid_auth_callback=lambda: fired.append(True) ) await manager._start_protocol() await _connect(manager, client) assert manager.protocol_state == "auth_rejected" assert fired == [True], "the entry's reauth/pair flow is the answer" assert "InvalidAuth" in (manager.protocol_last_error or "") assert client.keep_reconnecting_calls == 0 connect_calls = client.connect_calls await asyncio.sleep(0.05) assert client.connect_calls == connect_calls, ( "retrying a rejected certificate can never succeed" ) await manager.async_stop() async def test_auth_rejection_after_a_session_marks_the_state(self): client = FakeProtocolClient() manager = _protocol_manager(client) await manager._start_protocol() await _connect(manager, client) assert manager.protocol_state == "connected" client.fire_invalid_auth() assert manager.protocol_state == "auth_rejected" assert manager.protocol_last_error # readable, not only logged await manager.async_stop() class TestHonestSendFailures: """t15: a send that could not run must never look like one that did.""" async def test_send_without_a_session_warns_and_records(self, caplog): client = FakeProtocolClient() manager = _protocol_manager(client) await manager._start_protocol() await _connect(manager, client) client._remote_message_protocol = None # session lost (library no-ops sends) with caplog.at_level("WARNING"): sent = await manager.send_protocol_key("POWER") assert sent is False assert manager.protocol_failures == 1 assert "POWER" in (manager.protocol_last_error or "") assert "ConnectionClosed" in (manager.protocol_last_error or "") assert any( "did not run" in rec.message for rec in caplog.records ), "the failure is a WARNING carrying what happened" assert client.keys_sent == [] await manager.async_stop() async def test_launch_without_a_transport_warns_and_records(self, caplog): manager = _protocol_manager(None) # protocol disabled for this entry with caplog.at_level("WARNING"): sent = await manager.launch_app("com.netflix.ninja") assert sent is False assert manager.protocol_failures == 1 assert "no protocol transport" in (manager.protocol_last_error or "") assert any("did not run" in rec.message for rec in caplog.records) await manager.async_stop() async def test_a_working_send_is_not_counted_as_a_failure(self): client = FakeProtocolClient() manager = _protocol_manager(client) await manager._start_protocol() await _connect(manager, client) assert await manager.send_protocol_key("POWER") is True assert await manager.launch_app("com.netflix.ninja") is True assert client.keys_sent == [("POWER", "SHORT")] assert client.apps_launched == ["com.netflix.ninja"] assert manager.protocol_failures == 0 assert manager.protocol_last_error is None await manager.async_stop() async def test_availability_push_drives_the_connected_state(self): client = FakeProtocolClient() manager = _protocol_manager(client) await manager._start_protocol() await _connect(manager, client) client.push_is_available(False) await _drain_loop() assert manager.protocol_state == "disconnected" assert manager.protocol_connected is False client.push_is_available(True) await _drain_loop() assert manager.protocol_state == "connected" await manager.async_stop() async def test_a_push_proves_an_authenticated_session(self): """The library exposes no is_paired: a push is the only proof there is.""" client = FakeProtocolClient(connect_error=CannotConnect("down")) manager = _protocol_manager(client) await manager._start_protocol() await _connect(manager, client) assert manager.protocol_ever_authenticated is False client.push_current_app("com.netflix.ninja") await _drain_loop() assert manager.protocol_ever_authenticated is True await manager.async_stop() async def test_transport_state_is_off_without_a_client(self): manager = _protocol_manager(None) await manager._start_protocol() assert manager.protocol_state == "off" assert manager.protocol_connected is False assert manager.protocol_ever_authenticated is False await manager.async_stop() class TestLifecycle: async def test_stop_disconnects_lib_owned_reconnect_task(self): """The library owns keep_reconnecting's task; closing the client tears it down — the manager never wraps that task itself.""" client = FakeProtocolClient() manager = _protocol_manager(client) await manager._start_protocol() await _connect(manager, client) task = client._reconnect_task assert task is not None await manager.async_stop() await _drain_loop() # let the lib-owned task settle its cancellation assert client.disconnect_calls == 1 assert task.cancelled() assert manager._protocol is None async def test_protocol_with_factory_settings_builds_client(self, monkeypatch): client = FakeProtocolClient() built = [] def fake_builder(*args, **kwargs): built.append(kwargs) return client monkeypatch.setattr(ShieldConnectionManager, "build_protocol_client", fake_builder) manager = ShieldConnectionManager( hass=None, entry=None, protocol_settings={ "host": "shield.local", "client_name": "Home Assistant", "certfile": "/tmp/cert.pem", "keyfile": "/tmp/key.pem", }, ) await manager._start_protocol() assert manager._protocol is client assert built kwargs = built[0] assert kwargs["host"] == "shield.local" assert kwargs["client_name"] == "Home Assistant" assert kwargs["certfile"] == "/tmp/cert.pem" assert kwargs["keyfile"] == "/tmp/key.pem" await manager.async_stop() async def test_passive_without_protocol_configuration(self): manager = _protocol_manager(client=None) await manager._start_protocol() # no factory, no settings → no-op assert manager._protocol is None assert manager.is_on is None assert manager.is_available is None assert manager.current_app is None assert manager.volume_info is None await manager.async_stop() class TestPushEdgeCases: async def test_push_after_loop_close_falls_back_to_direct_notify(self): """Push callbacks that outlive a closed loop must not crash.""" client = FakeProtocolClient() manager = _protocol_manager(client) notified = [] manager.register_listener(lambda: notified.append(True)) await manager._start_protocol() loop = asyncio.new_event_loop() loop.close() manager._protocol_loop = loop # stale/closed loop (mid-shutdown state) client.push_is_on(True) await _drain_loop() assert manager.is_on is True # store stamped via direct-notify path assert notified # listeners still pinged synchronously await manager.async_stop() async def test_store_ttl_applies_to_protocol_fields(self): now = [1000.0] store = StateStore(ttl=60.0, clock=lambda: now[0]) client = FakeProtocolClient() manager = _protocol_manager(client, store=store) await manager._start_protocol() client.push_is_on(True) await _drain_loop() now[0] += 60.1 assert manager.is_on is None # expired like every other store field assert manager.store.get(FIELD_IS_AVAILABLE) is None await manager.async_stop() async def test_pushes_are_stamped_with_transition_time(self): client = FakeProtocolClient() manager = _protocol_manager(client) await manager._start_protocol() before = time.monotonic() client.push_is_available(True) await _drain_loop() after = time.monotonic() stamped_at = manager.store._values[FIELD_IS_AVAILABLE][0] assert before <= stamped_at <= after await manager.async_stop() class FakeAdbForOutage: """ADB poller stub for the outage test (wake poll fails).""" def __init__(self): self.connected = True self.awake = None self.disconnect_calls = 0 async def connect(self): self.connected = True return True async def disconnect(self): self.disconnect_calls += 1 self.connected = False async def is_awake(self): return self.awake if self.connected else None class TestAdbOutageKeepsProtocolTruth: async def test_note_failure_clears_only_adb_fields(self): """F2: an ADB outage must not clobber device-reported protocol truth.""" poller = FakeAdbForOutage() poller.connected = True poller.awake = None # wake poll fails → cycle failure path client = FakeProtocolClient() manager = ShieldConnectionManager( hass=None, entry=None, poller=poller, protocol=client ) await manager._start_protocol() client.push_is_on(True) client.push_is_available(True) client.push_current_app("com.netflix.ninja") client.push_volume_info({"level": 5, "max": 15, "muted": False}) manager.store.set(FIELD_AWAKE, True) manager.store.set(FIELD_SESSIONS, ["session-data"]) await manager._watchdog_cycle(5.0) # ADB outage → _note_failure # ADB-derived fields cleared (regenerated by the watchdog) assert manager.store.get(FIELD_AWAKE) is None assert manager.store.get(FIELD_SESSIONS, []) == [] # protocol push truth survives the ADB outage (TTL expiry rules it) assert manager.is_on is True assert manager.is_available is True assert manager.current_app == "com.netflix.ninja" assert manager.volume_info == (5.0, 15.0, False) await manager.async_stop() class TestAdbStaysThePrimarySource: """t15: arming the protocol must not promote its pushes over ADB's readings. The pushes this unit turns ON (is_on / is_available / current_app / volume) had NEVER been live here — ``keep_reconnecting`` alone never connected — so the whole protocol surface is new traffic next to an ADB media-session poller the user relies on. The store's fields are disjoint by design; these tests pin that, and that an ADB-only entry keeps working with only the protocol surface degraded. """ @staticmethod def _adbreath(manager): """Every field the ADB poller owns, stamped, and read back.""" manager.store.set(FIELD_AWAKE, True) manager.store.set(FIELD_SESSIONS, ["session-data"]) manager.store.set(FIELD_AUDIO_PLAYBACK, ["com.audio.last"]) manager.store.set(FIELD_RUNNING_APPS, [{"package": "com.run.now"}]) manager.store.set(FIELD_FOREGROUND, "com.adb.foreground") return { field: manager.store.get(field) for field in ( FIELD_AWAKE, FIELD_SESSIONS, FIELD_AUDIO_PLAYBACK, FIELD_RUNNING_APPS, FIELD_FOREGROUND, ) } async def test_a_protocol_push_never_overwrites_an_adb_reading(self): client = FakeProtocolClient() manager = _protocol_manager(client) await manager._start_protocol() await _connect(manager, client) before = self._adbreath(manager) client.push_is_on(True) client.push_is_available(True) client.push_current_app("com.protocol.foreground") client.push_volume_info({"level": 5, "max": 15, "muted": False}) await _drain_loop() assert { field: manager.store.get(field) for field in before } == before, "ADB's own fields are untouched by every protocol push" # ...and the protocol wrote exactly its own four assert manager.is_on is True assert manager.is_available is True assert manager.current_app == "com.protocol.foreground" assert manager.volume_info == (5.0, 15.0, False) await manager.async_stop() async def test_the_foreground_identity_falls_back_to_adb(self): """No push yet: the ADB parse answers, and nothing is invented.""" client = FakeProtocolClient(connect_error=CannotConnect("down")) manager = _protocol_manager(client) await manager._start_protocol() await _connect(manager, client) self._adbreath(manager) assert manager.current_app is None, "no protocol push ever arrived" assert manager.foreground_app == "com.adb.foreground" assert manager.foreground_source == "adb", "the tier is named, not implied" await manager.async_stop() async def test_a_push_wins_while_fresh_and_the_adb_reading_survives_it(self): """The documented priority, and no loss: an expired push re-exposes ADB.""" now = [1000.0] store = StateStore(ttl=60.0, clock=lambda: now[0]) client = FakeProtocolClient() manager = _protocol_manager(client, store=store) await manager._start_protocol() await _connect(manager, client) self._adbreath(manager) client.push_current_app("com.protocol.foreground") await _drain_loop() assert manager.foreground_app == "com.protocol.foreground" assert manager.foreground_source == "protocol" assert manager.store.get(FIELD_FOREGROUND) == "com.adb.foreground", ( "the push wrote its OWN field — the ADB reading was not clobbered" ) now[0] += 60.1 # both fields expire on the store's own TTL, not by a push assert manager.current_app is None assert manager.store.get(FIELD_FOREGROUND) is None # re-stamped (the next ADB poll), the ADB reading answers again untouched manager.store.set(FIELD_FOREGROUND, "com.adb.foreground") assert manager.foreground_app == "com.adb.foreground" assert manager.foreground_source == "adb" await manager.async_stop() async def test_an_adb_only_entry_keeps_working(self): """No protocol client at all: ADB truth refreshes, only the transport degrades.""" poller = FakeAdbForOutage() poller.connected = True poller.awake = True manager = ShieldConnectionManager(hass=None, entry=None, poller=poller) await manager._start_protocol() assert manager._protocol is None assert manager.protocol_state == "off" assert manager.protocol_connected is False assert manager.protocol_ever_authenticated is False await manager.async_stop() class TestImportGuard: def test_missing_library_degrades_to_adb_only(self, monkeypatch, caplog): """F1: a protocol-enabled entry without androidtvremote2 keeps running ADB-only — the guarded factory logs a warning and builds nothing instead of raising at setup.""" import logging monkeypatch.setitem(sys.modules, "androidtvremote2", None) with caplog.at_level(logging.WARNING): client = ShieldConnectionManager.build_protocol_client( "shield.local", "Home Assistant", "/tmp/c.pem", "/tmp/k.pem" ) assert client is None assert any("degrades to ADB-only" in r.message for r in caplog.records) async def test_settings_entry_without_library_stays_passive(self, monkeypatch): manager = ShieldConnectionManager( hass=None, entry=None, protocol_settings={ "host": "shield.local", "client_name": "HA", "certfile": "/tmp/c.pem", "keyfile": "/tmp/k.pem", }, ) monkeypatch.setitem(sys.modules, "androidtvremote2", None) await manager._start_protocol() assert manager._protocol is None # passive, no crash await manager.async_stop() class TestVolumeTuple: def test_normalizes_typed_dict(self): assert volume_tuple({"level": 7, "max": 15, "muted": True}) == (7.0, 15.0, True) def test_defaults_on_partial_dict(self): assert volume_tuple({"muted": False}) == (0.0, 1.0, False) def test_none_and_garbage_yield_none(self): assert volume_tuple(None) is None assert volume_tuple("garbage") is None def test_accepts_real_library_volume_info(self): androidtvremote2 = pytest.importorskip("androidtvremote2") info = androidtvremote2.VolumeInfo(level=3, max=15, muted=True) assert volume_tuple(info) == (3.0, 15.0, True)