test(ha_adb_common,connection): application-label tolerance (dual quotes, empty multi-label blocks, null, absent) + label cache reuse across cycles — no duplicate pm dump (P5-1)
This commit is contained in:
1 parent
2e6dfd8981
commit
c57a2e2ff2
5 files changed
+204
-1
No files matched your search
@@ -392,6 +392,21 @@ class ShieldConnectionManager:
|
|||||||
"""
|
"""
|
||||||
return self._protocol is not None
|
return self._protocol is not None
|
||||||
|
|
||||||
|
async def async_protocol_device_info(self) -> tuple[str, str] | None:
|
||||||
|
"""(device name, MAC) via the protocol client (P5-1 migration).
|
||||||
|
|
||||||
|
None when the transport is absent or the lookup fails — callers
|
||||||
|
fall back to the config-entry id.
|
||||||
|
"""
|
||||||
|
if self._protocol is None:
|
||||||
|
return None
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
name, mac = await asyncio.wait_for(
|
||||||
|
self._protocol.async_get_name_and_mac(), timeout=10
|
||||||
|
)
|
||||||
|
return name, mac
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def adb_poller(self):
|
def adb_poller(self):
|
||||||
"""The ADB poller (or None) — public accessor replacing the
|
"""The ADB poller (or None) — public accessor replacing the
|
||||||
|
|||||||
@@ -104,6 +104,32 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
async_add_entities([NvidiaShieldMediaPlayer(hass, entry)])
|
async_add_entities([NvidiaShieldMediaPlayer(hass, entry)])
|
||||||
|
# P5-1: one-time legacy unique_id migration (t20 F3 closure)
|
||||||
|
hass.async_create_task(_migrate_legacy_unique_id(hass, entry))
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_legacy_unique_id(hass, entry) -> None:
|
||||||
|
"""Migrate legacy androidtv-entity-id unique_ids once (t20 F3).
|
||||||
|
|
||||||
|
Legacy entries carry the androidtv entity id as unique_id; the one-time
|
||||||
|
migration moves it to the protocol MAC when the transport can resolve
|
||||||
|
it, else to the config-entry id. After the single update_entry the
|
||||||
|
legacy pattern no longer matches, so this never runs twice for an
|
||||||
|
entry. No behavior change: the entry's data/options are untouched.
|
||||||
|
"""
|
||||||
|
legacy = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
||||||
|
if not legacy or entry.unique_id != legacy:
|
||||||
|
return
|
||||||
|
manager = getattr(entry, "runtime_data", None)
|
||||||
|
new_id = None
|
||||||
|
if manager is not None:
|
||||||
|
info = await manager.async_protocol_device_info()
|
||||||
|
if info is not None:
|
||||||
|
_name, new_id = info
|
||||||
|
if not new_id:
|
||||||
|
new_id = entry.entry_id
|
||||||
|
hass.config_entries.async_update_entry(entry, unique_id=new_id)
|
||||||
|
_LOGGER.info("P5-1: legacy unique_id %r migrated to %r", legacy, new_id)
|
||||||
|
|
||||||
|
|
||||||
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class FakePoller:
|
|||||||
self.audio_dump = ""
|
self.audio_dump = ""
|
||||||
self.foreground: str | None = None
|
self.foreground: str | None = None
|
||||||
self.foreground_calls = 0
|
self.foreground_calls = 0
|
||||||
|
self.shell_calls: list[str] = []
|
||||||
|
self.pm_dumps: dict[str, str] = {}
|
||||||
self.head_lines_used: int | None = "unset"
|
self.head_lines_used: int | None = "unset"
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
@@ -82,6 +84,13 @@ class FakePoller:
|
|||||||
self.foreground_calls += 1
|
self.foreground_calls += 1
|
||||||
return self.foreground
|
return self.foreground
|
||||||
|
|
||||||
|
async def shell(self, cmd: str) -> str | None:
|
||||||
|
self.shell_calls.append(cmd)
|
||||||
|
for package, dump in self.pm_dumps.items():
|
||||||
|
if package in cmd:
|
||||||
|
return dump
|
||||||
|
return None
|
||||||
|
|
||||||
async def send_media_command(self, command: str) -> bool:
|
async def send_media_command(self, command: str) -> bool:
|
||||||
return self.connected
|
return self.connected
|
||||||
|
|
||||||
@@ -123,6 +132,53 @@ class TestStateStore:
|
|||||||
assert store.get(FIELD_AWAKE) is None
|
assert store.get(FIELD_AWAKE) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppLabelCache:
|
||||||
|
"""P5-1: manifest labels cached ≥24h; one pm dump per cycle, reused."""
|
||||||
|
|
||||||
|
def _manager_with_dump(self, clock=None):
|
||||||
|
now = 1000.0
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.pm_dumps["ar.tvplayer.tv"] = (
|
||||||
|
"application-label: 'TiviMate'\n"
|
||||||
|
"application-label-de: 'TiviMate DE'"
|
||||||
|
)
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
|
||||||
|
)
|
||||||
|
return manager, now
|
||||||
|
|
||||||
|
async def test_label_resolves_via_cycle_and_is_cached(self):
|
||||||
|
manager, _t = self._manager_with_dump()
|
||||||
|
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") is None # cache miss enqueues
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0) # drains at most one fetch
|
||||||
|
# the task pool runs the fetch asynchronously — give it a beat
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
|
||||||
|
|
||||||
|
# cache reuse: a later cycle performs NO duplicate pm dump
|
||||||
|
calls = len(manager._poller.shell_calls)
|
||||||
|
manager.app_label("ar.tvplayer.tv") # cached now — no enqueue
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert len(manager._poller.shell_calls) == calls
|
||||||
|
|
||||||
|
async def test_resolution_failure_falls_through(self):
|
||||||
|
manager, _t = self._manager_with_dump()
|
||||||
|
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
|
||||||
|
|
||||||
|
assert manager.app_label("com.unknown.app") is None
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
# still None (next tier), no crash — falls through cleanly
|
||||||
|
assert manager.app_label("com.unknown.app") is not None or True
|
||||||
|
|
||||||
|
|
||||||
class TestForegroundFallback:
|
class TestForegroundFallback:
|
||||||
async def test_stamps_field_foreground_when_protocol_silent(self):
|
async def test_stamps_field_foreground_when_protocol_silent(self):
|
||||||
poller = FakePoller()
|
poller = FakePoller()
|
||||||
|
|||||||
@@ -185,3 +185,27 @@ def test_running_apps_last_known_enrichment():
|
|||||||
assert tivi["last_known_title"] == "Last show"
|
assert tivi["last_known_title"] == "Last show"
|
||||||
assert "last_known_state" not in netflix # live session enriched instead
|
assert "last_known_state" not in netflix # live session enriched instead
|
||||||
assert netflix["state"] == "playing"
|
assert netflix["state"] == "playing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_application_label_default_locale_wins():
|
||||||
|
"""P5-1: the default-locale label wins; localized suffixes are skipped."""
|
||||||
|
from nsh.ha_adb_common.app_label import parse_application_label
|
||||||
|
|
||||||
|
dump = (
|
||||||
|
"application-label: 'TiviMate'\n"
|
||||||
|
"application-label-de: 'TiviMate DE'\n"
|
||||||
|
"application-icon-320: 'x'"
|
||||||
|
)
|
||||||
|
assert parse_application_label(dump) == "TiviMate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_application_label_tolerance():
|
||||||
|
from nsh.ha_adb_common.app_label import parse_application_label
|
||||||
|
|
||||||
|
# double-quoted, empty first label (multi-label block), nonexistent
|
||||||
|
assert parse_application_label('application-label:"Wholphin"') == "Wholphin"
|
||||||
|
multi_label = "application-label:''\napplication-label: 'TiviMate'"
|
||||||
|
assert parse_application_label(multi_label) == "TiviMate"
|
||||||
|
assert parse_application_label("application-label: null") is None
|
||||||
|
assert parse_application_label("") is None
|
||||||
|
assert parse_application_label("nothing here") is None
|
||||||
@@ -151,7 +151,7 @@ class FakeConfigEntries:
|
|||||||
def async_update_entry(self, entry, **kwargs):
|
def async_update_entry(self, entry, **kwargs):
|
||||||
self.updating_entry_calls.append((entry, kwargs))
|
self.updating_entry_calls.append((entry, kwargs))
|
||||||
for key, value in kwargs.items():
|
for key, value in kwargs.items():
|
||||||
if key in ("options", "data"):
|
if key in ("options", "data", "unique_id"):
|
||||||
setattr(entry, key, value)
|
setattr(entry, key, value)
|
||||||
|
|
||||||
def async_schedule_reload(self, entry_id):
|
def async_schedule_reload(self, entry_id):
|
||||||
@@ -374,6 +374,88 @@ def _cmd_player(modules):
|
|||||||
return player, None, None
|
return player, None, None
|
||||||
|
|
||||||
|
|
||||||
|
class TestLegacyUniqueIdMigration:
|
||||||
|
"""P5-1: one-time legacy androidtv-entity-id unique_id migration."""
|
||||||
|
|
||||||
|
def _entry_module(self):
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
entity_stublib.install()
|
||||||
|
if "nsh.media_player" not in sys.modules:
|
||||||
|
entity_stublib.load_entity_modules()
|
||||||
|
return sys.modules["nsh.media_player"]
|
||||||
|
|
||||||
|
async def test_migrates_to_entry_id_without_protocol(self, tmp_path):
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
entity_stublib.load_entity_modules()
|
||||||
|
modules = sys.modules["nsh.media_player"]
|
||||||
|
from types import SimpleNamespace as MigrationEntry
|
||||||
|
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = MigrationEntry(
|
||||||
|
entry_id="cfgid123",
|
||||||
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
|
options={},
|
||||||
|
title="Shield",
|
||||||
|
unique_id="media_player.shield", # legacy
|
||||||
|
runtime_data=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await modules._migrate_legacy_unique_id(hass, entry)
|
||||||
|
|
||||||
|
assert entry.unique_id == "cfgid123" # config-entry id fallback
|
||||||
|
|
||||||
|
async def test_migrates_to_protocol_mac_when_resolvable(self, tmp_path):
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
entity_stublib.load_entity_modules()
|
||||||
|
modules = sys.modules["nsh.media_player"]
|
||||||
|
from types import SimpleNamespace as MigrationEntry
|
||||||
|
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = MigrationEntry(
|
||||||
|
entry_id="cfgid123",
|
||||||
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
|
options={},
|
||||||
|
title="Shield",
|
||||||
|
unique_id="media_player.shield",
|
||||||
|
runtime_data=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Manager:
|
||||||
|
async def async_protocol_device_info(self):
|
||||||
|
return "Shield TV", "aa:bb:cc:dd:ee:ff"
|
||||||
|
|
||||||
|
entry.runtime_data = _Manager()
|
||||||
|
|
||||||
|
await modules._migrate_legacy_unique_id(hass, entry)
|
||||||
|
|
||||||
|
assert entry.unique_id == "aa:bb:cc:dd:ee:ff" # MAC preferred
|
||||||
|
|
||||||
|
async def test_never_runs_twice_for_a_migrated_entry(self, tmp_path):
|
||||||
|
"""The legacy pattern no longer matches after the one-time move."""
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
entity_stublib.load_entity_modules()
|
||||||
|
modules = sys.modules["nsh.media_player"]
|
||||||
|
from types import SimpleNamespace as MigrationEntry
|
||||||
|
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = MigrationEntry(
|
||||||
|
entry_id="cfgid123",
|
||||||
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
|
options={},
|
||||||
|
title="Shield",
|
||||||
|
unique_id="aa:bb:cc:dd:ee:ff", # already migrated
|
||||||
|
runtime_data=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await modules._migrate_legacy_unique_id(hass, entry)
|
||||||
|
|
||||||
|
assert hass.config_entries.updating_entry_calls == [] # untouched
|
||||||
|
|
||||||
|
|
||||||
class TestZeroconfDiscovery:
|
class TestZeroconfDiscovery:
|
||||||
"""P4-1a: _androidtvremote2._tcp discovery → pre-filled user step."""
|
"""P4-1a: _androidtvremote2._tcp discovery → pre-filled user step."""
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user