feat(media_player): per-command script overrides (t44)
The user's remaining universal-media-player dependency: some commands need hardware this integration deliberately does not drive — a speaker switch that owns the volume, an AVR input that has to move with the Shield, a mute that has to hit two devices. One script per command is the escape hatch, and the rule is absolute: a configured command calls its script INSTEAD of the native protocol/ADB path, and with nothing configured every command behaves exactly as it did (the whole existing suite passes untouched, which is what "byte-identical" means here). * const.py owns the option: CONF_COMMAND_SCRIPTS (one dict, keyed by command), SCRIPT_COMMANDS (turn_on, turn_off, volume_up, volume_down, volume_set, volume_mute, select_source) and command_scripts(), the ONE normalizer of the stored shape — options round-trip through HA storage and can be hand-edited, so a value that is not a non-empty string for a known command is dropped instead of reaching the service layer. * media_player.py: async_run_command_script() calls script.turn_on with the script entity and its variables, blocking=True so a failure surfaces HERE — a mapped command that cannot run raises HomeAssistantError naming the command and script rather than falling back silently or doing nothing. Every command method checks its script first; volume_set passes `level` and select_source passes `app_name`. * volume_mute also passes `is_volume_muted`: without it a mute script cannot tell muting from unmuting, which is the case the user asked for. * volume_set and select_source have NO native path (C-VOL; app launching goes through play_media), so they are advertised only when configured — the feature flag is what makes HA offer the command at all. Unset leaves the feature set exactly as it was. * remote.py shares async_run_command_script for POWER: turn_on/turn_off is one configuration and the remote entity is the other route to it, so a script that owns the power sequence owns it on both. * config_flow.py: one entity selector per command (domain: script), stored as the single dict option, prefilled from the entry's options and cleared by saving without a value — the editor is the truth, as with the app map editor. Two things the harness caught, worth recording: `MediaPlayerEntityFeature` has no SET_VOLUME (the real member is VOLUME_SET — the stub enum and the drift engine disagree with a wrong name), and the stub `homeassistant.exceptions` had no HomeAssistantError, so the stub now mirrors the real inheritance (ServiceValidationError derives from it). tests/test_command_scripts.py covers each command mapped and unmapped, the exact variable payloads, the native path untouched when unmapped (protocol key, then the ADB fallthrough with the protocol down), the rejecting script's error, hand-edited option shapes, and the feature set. test_config_flow_ux.py covers the selectors, the stored shape, prefill and clearing. New tests leaked the harness into the next file at first (a missing per-file scope); that is fixed, and tests/test_harness_scoping.py now asserts that every file loading the harness scopes it, so the next such mistake fails where it is made. Verified: 401 passed / 2 skipped, 402 / 1 with androidtvremote2, reverse file order 401 / 2, ruff clean, and the drift engine exits 0 on a real-HA interpreter (23 modules, 59 imports, 157 relative imports, 36 call arguments, 6 awaited HA calls).
This commit is contained in:
1 parent
2e1d162ef4
commit
89c05f1dcd
11 files changed
+627
-21
No files matched your search
@@ -33,12 +33,15 @@ from conftest import _SHIELD_DIR, _load_module
|
||||
from nsh.const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_PORT,
|
||||
CONF_COMMAND_SCRIPTS,
|
||||
CONF_HOST,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
CONF_PAIRING_CODE,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_SKIP_PAIRING,
|
||||
DEFAULT_ADB_PORT,
|
||||
SCRIPT_COMMANDS,
|
||||
SCRIPT_FIELD_PREFIX,
|
||||
)
|
||||
|
||||
BASE = Path(__file__).resolve().parent.parent / "custom_components" / "nvidia_shield"
|
||||
@@ -1448,6 +1451,97 @@ class TestCopyContract:
|
||||
assert field in step["data"], f"options.{field} has no label"
|
||||
|
||||
|
||||
class TestCommandScriptOptions:
|
||||
"""t44: one script selector per command, stored as one dict option.
|
||||
|
||||
The escape hatch for hardware this integration does not drive — a speaker switch
|
||||
that owns the volume. Unset means the native protocol/ADB path, exactly as
|
||||
before, so the page has to make unsetting as easy as setting.
|
||||
"""
|
||||
|
||||
def _flow(self, tmp_path, options=None):
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldOptionsFlow()
|
||||
flow.config_entry = SimpleNamespace(
|
||||
options=dict(options or {}), entry_id="e1", title="Shield"
|
||||
)
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
return flow
|
||||
|
||||
@staticmethod
|
||||
def _submitted(schema):
|
||||
return getattr(schema, "schema", schema) or {}
|
||||
|
||||
async def test_a_script_selector_exists_for_every_command(self, tmp_path):
|
||||
flow = self._flow(tmp_path)
|
||||
|
||||
form = await flow.async_step_init(None)
|
||||
|
||||
fields = set(vol_mirror.declared_fields(form["data_schema"]))
|
||||
assert {f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS} <= fields
|
||||
|
||||
async def test_saving_stores_one_dict_option_and_no_flat_keys(self, tmp_path):
|
||||
flow = self._flow(tmp_path)
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{
|
||||
f"{SCRIPT_FIELD_PREFIX}volume_set": "script.shield_volume",
|
||||
f"{SCRIPT_FIELD_PREFIX}turn_on": "script.shield_wake",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_COMMAND_SCRIPTS] == {
|
||||
"volume_set": "script.shield_volume",
|
||||
"turn_on": "script.shield_wake",
|
||||
}
|
||||
# the per-command field is UI only: the service layer reads the dict
|
||||
assert not [key for key in result["data"] if key.startswith(SCRIPT_FIELD_PREFIX)]
|
||||
|
||||
async def test_configured_scripts_prefill_their_selectors(self, tmp_path):
|
||||
flow = self._flow(
|
||||
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake"}}
|
||||
)
|
||||
|
||||
form = await flow.async_step_init(None)
|
||||
|
||||
suggested = self._submitted(form["data_schema"]).get("__suggested__", {})
|
||||
assert suggested[f"{SCRIPT_FIELD_PREFIX}turn_on"] == "script.shield_wake"
|
||||
# an unconfigured command is not suggested anything
|
||||
assert f"{SCRIPT_FIELD_PREFIX}volume_up" not in suggested
|
||||
|
||||
async def test_clearing_every_script_removes_the_option(self, tmp_path):
|
||||
"""The editor is the truth: a save without scripts clears them."""
|
||||
flow = self._flow(
|
||||
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake"}}
|
||||
)
|
||||
|
||||
result = await flow.async_step_init({})
|
||||
|
||||
assert result["data"][CONF_COMMAND_SCRIPTS] == {}
|
||||
|
||||
async def test_a_stale_unknown_command_is_not_carried_forward(self, tmp_path):
|
||||
flow = self._flow(
|
||||
tmp_path,
|
||||
{CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake", "gone": "script.x"}},
|
||||
)
|
||||
|
||||
result = await flow.async_step_init({f"{SCRIPT_FIELD_PREFIX}turn_on": "script.a"})
|
||||
|
||||
assert result["data"][CONF_COMMAND_SCRIPTS] == {"turn_on": "script.a"}
|
||||
|
||||
async def test_the_page_still_renders_with_a_hand_edited_option(self, tmp_path):
|
||||
"""A string where the dict belongs must not break the form."""
|
||||
flow = self._flow(tmp_path, {CONF_COMMAND_SCRIPTS: "script.shield_wake"})
|
||||
|
||||
form = await flow.async_step_init(None)
|
||||
|
||||
assert form["type"] == "form"
|
||||
assert f"{SCRIPT_FIELD_PREFIX}turn_on" in vol_mirror.declared_fields(
|
||||
form["data_schema"]
|
||||
)
|
||||
|
||||
|
||||
class TestStringsTranslationsParity:
|
||||
"""Both copies of the UI copy must expose the same key tree."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user