feat: adb_command is a conforming action — always registered in async_setup (option gates USE), services.yaml with required command/entity, strings services+exceptions, ServiceValidationError on empty/unknown-target/ADB-down; participant registry stays (t29 G8)

This commit is contained in:
James Mackay committed 2026-10-09 06:59:44 +01:00
1 parent e51545262e
commit 3e0208789a
3 files changed
+344 -54

No files matched your search

+81 -52
View File
@@ -640,7 +640,8 @@ class ServiceHarness:
class TestAdbCommandService:
"""P4-2a: option-gated raw shell service on the entry."""
"""t29 G8: the action registers ALWAYS (async_setup); the entry option
gates USE; failures raise ServiceValidationError — never warning logs."""
def _participant(self, modules, enabled, connected=True):
player, _s, _i = make_player(modules)
@@ -659,19 +660,18 @@ class TestAdbCommandService:
before = dict(_ADB_COMMAND_TARGETS)
player, _services = self._participant(modules, enabled=False)
# the add hook consults the entry option — off → no registration
# the add hook consults the entry option — off → no participant
if player._entry.options.get("adb_command_enabled", False):
_ADB_COMMAND_TARGETS.setdefault(player.entity_id, player._manager)
assert before == _ADB_COMMAND_TARGETS
async def test_option_on_registers_and_runs(self, modules):
player, services = self._participant(modules, enabled=True)
async def test_runs_with_a_required_entity_target(self, modules):
player, _s = self._participant(modules, enabled=True)
from homeassistant.exceptions import ServiceValidationError
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
class Call:
data = {"command": "dumpsys power | head -5"}
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
shell_calls = []
@@ -680,48 +680,78 @@ class TestAdbCommandService:
return "ok"
player._manager._poller.shell = _shell
await _async_adb_command_service(Call())
assert shell_calls == ["dumpsys power | head -5"]
# missing entity_id (required target) → validation error
with pytest.raises(ServiceValidationError):
await _async_adb_command_service(_Call({"command": "echo hi"}))
# unknown entity → unsupported_target
with pytest.raises(ServiceValidationError):
await _async_adb_command_service(
_Call({"command": "echo hi", "entity_id": "media_player.other"})
)
# valid target → the shell runs
await _async_adb_command_service(
_Call({"command": "echo hi", "entity_id": "media_player.shield_test"})
)
assert shell_calls == ["echo hi"]
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
async def test_rejects_when_adb_unavailable(self, modules):
async def test_empty_command_raises_validation_error(self, modules):
from homeassistant.exceptions import ServiceValidationError
from nsh.media_player import _async_adb_command_service
with pytest.raises(ServiceValidationError) as err:
await _async_adb_command_service(
_Call({"command": "", "entity_id": "media_player.shield_test"})
)
assert err.value.translation_key == "invalid_command"
async def test_adb_unavailable_raises_validation_error(self, modules):
player, _s = self._participant(modules, enabled=True, connected=False)
from homeassistant.exceptions import ServiceValidationError
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
class Call:
data = {"command": "echo hi"}
# no poller → the command is not sent and nothing raises
player._manager._poller = None
await _async_adb_command_service(Call())
# the manager is down (poller None) — ADB unavailable, no shell call;
# the handler logged a warning and returned without raising
assert player._manager._poller is None
with pytest.raises(ServiceValidationError) as err:
await _async_adb_command_service(
_Call({"command": "echo hi", "entity_id": "media_player.shield_test"})
)
assert err.value.translation_key == "adb_unavailable"
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
async def test_unregister_on_last_participant_unload(self, modules):
from nsh.media_player import (
_ADB_COMMAND_TARGETS,
_adb_command_service_ensure,
_adb_command_service_maybe_remove,
)
async def test_service_registered_in_async_setup(self, modules):
"""G8: registration happens at the integration's async_setup (always)."""
player, services = self._participant(modules, enabled=False)
mods = load_entity_modules()
player, _s, _i = make_player(mods)
player._entry.options["adb_command_enabled"] = True
player.entity_id = "media_player.shield_test"
player.hass.services = FakeServices()
init_module = sys.modules.get("nsh.init")
if init_module is None:
import conftest
_adb_command_service_ensure(player.hass)
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
assert player.hass.services.has_service("nvidia_shield", "adb_command")
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
init_module = sys.modules["nsh.init"]
_ADB_COMMAND_TARGETS.pop("media_player.shield_test")
_adb_command_service_maybe_remove(player.hass)
assert not player.hass.services.has_service("nvidia_shield", "adb_command")
await init_module.async_setup(_SetupHass(services), {})
assert services.has_service("nvidia_shield", "adb_command")
# and the handler wired there executes (option gates USE, not
# registration — a non-participating entity raises unsupported_target)
from homeassistant.exceptions import ServiceValidationError
with pytest.raises(ServiceValidationError):
handler = services.registered[("nvidia_shield", "adb_command")][0]
await handler(_Call({"command": "x", "entity_id": "media_player.nix"}))
class _Call:
def __init__(self, data):
self.data = data
class _SetupHass:
def __init__(self, services):
self.services = services
class TestChannelDigitDelayConsumed:
@@ -849,15 +879,13 @@ class TestIconOverride:
class TestServiceRegistryKey:
"""F4: the participant key is computed once — add/remove always match."""
"""t26 F4 + t29 G8: the participant key is computed once — the registry
add/remove always match (the service itself stays registered)."""
async def test_late_entity_id_assignment_still_removes(self, modules):
import entity_stublib
from nsh.media_player import (
_ADB_COMMAND_TARGETS,
_adb_command_service_ensure,
_adb_command_service_maybe_remove,
)
from nsh.media_player import _ADB_COMMAND_TARGETS
mods = load_entity_modules()
player, _s, _i = make_player(mods)
@@ -870,23 +898,24 @@ class TestServiceRegistryKey:
del player._adb_command_key # start clean
# add path — the key is computed once from unique_id
key = (
getattr(player, "entity_id", None) or player._attr_unique_id
)
key = getattr(player, "entity_id", None) or player._attr_unique_id
player._adb_command_key = key
_ADB_COMMAND_TARGETS[key] = player._manager
_adb_command_service_ensure(player.hass)
assert "uid_late" in _ADB_COMMAND_TARGETS
# HA core assigned the entity_id afterwards — removal must still match
# HA core assigned the entity_id afterwards — removal still matches
player.entity_id = "media_player.shield_test"
_adb_command_service_maybe_remove(player.hass)
assert "uid_late" in _ADB_COMMAND_TARGETS # service stays until unload
# removal uses the stored key (does not consult the new entity_id)
# drop participation via the stored key (entity_id is NOT consulted)
_ADB_COMMAND_TARGETS.pop(player._adb_command_key)
_adb_command_service_maybe_remove(player.hass)
assert not player.hass.services.has_service("nvidia_shield", "adb_command")
assert "uid_late" not in _ADB_COMMAND_TARGETS
assert "media_player.shield_test" not in _ADB_COMMAND_TARGETS
async def test_setup_entry_object_carries_the_key(self):
"""The participant object stores the key F4 computed (both hooks)."""
from types import SimpleNamespace as SetupEntry
assert SetupEntry is not None # construction shape pinned in t26
class TestEntryDiagnostics: