feat(intents): the Intents options screen, the seed, the strings and the tests (t17, part 2)
Completes the intents feature: part 1 shipped the engine, the buttons and the
action; this is the surface a user actually touches, plus the evidence.
THE SCREEN (config_flow.py). A new `intents` row on the Configure menu opens the
same editor shape as the Apps table, because HA has no table selector: a menu of
row actions, one row per step. Add and edit share ONE form, reached through a
select of the registered intents whose last option is an explicit "Add a new
intent" sentinel — so adding is a choice the user can see, not an empty-string
convention. The per-intent form is name, package, component OR action,
categories, a data template and the declared variables as text
(`key:type[:required][=value]`, blank lines free, `#` comments). Removal asks for
confirmation and takes nothing until the user confirms. "Scan an app's intents"
asks for ONE package (a dropdown of discovered apps with free text), runs the
device-side scan, and offers the candidates; each candidate is marked when it
looks like an internal hook, and a candidate whose path is a PATTERN prefills no
URL with a note saying why, because a pattern is not a link. A scan that cannot
run reports t16's status vocabulary in words a user can act on — ADB off, ADB
down, or the app really advertising nothing are three different sentences.
Nothing half-stored: a submission that fails writes NOTHING and the form comes
back naming each problem (a component without its slash, a package with a space,
a duplicate name, a data template using an undeclared placeholder, a fields line
that does not parse), and a successful edit REPLACES the row where it already is
instead of moving it to the end of the list under the user's eyes.
The screen's reader of `registered_intents` is a second implementation of the
runtime's one — deliberately, because config_flow must stay importable without
Home Assistant's media_player platform. A test drives BOTH over the same battery
of rows (valid, junk, duplicate id, mapping-shaped blob, over-long list) and
requires identical results, so the two cannot drift into meaning different
things.
THE SEED. `registered_intents:` in `configuration.yaml` is validated by the SAME
validator the form uses, so a seeded intent is identical to a typed one; a row it
refuses is dropped with a warning NAMING the row and the reason (a seed is
configuration.yaml: there is no form to show an error on, and a silent skip would
be indistinguishable from a typo). It is honoured only while the UI holds nothing
— at creation, because the Intents screen never writes a seed back and the import
step aborts for a known host first.
THE STRINGS (both files, byte-identical) carry every new step, field, menu row
and the action's own description, plus the exceptions the action raises. The two
shared target errors (`unsupported_target`, `ambiguous_target`) now interpolate
`{service}`, because `send_intent` reuses them verbatim and "for this action" was
`adb_command`-specific copy.
THE TESTS (+72 in tests/test_intents_registration.py, and repaired coverage in
test_command_routing.py). The command: the exact `am start` line per declared
type, a variable containing `"; rm -rf /"` reaching the device as ONE quoted
argument (asserted through shlex, not by eyeballing), a value that looks like a
placeholder being data rather than a second substitution, `{id.__class__}` left
alone because there is no format()/eval() path at all, and each refusal naming
the field. The scan: the status sentences, the candidate prefill, the pattern
case, an internal-looking candidate still being offered. The seed: warned drops,
and the seeded row being byte-identical to the form's. The buttons: one entity
per registration, its identity keyed by the intent's id so a rename does not
orphan it, defaults sent, and a press that cannot reach the Shield RAISING
instead of doing nothing.
test_command_routing's registry test used to hand-write the dict and set the OLD
attribute name, which is exactly why it never noticed that the add hook registered
`None` for a real entry and that the remove hook dropped nothing. It now drives
the real hooks, and pins both registries plus the ruling that registered intents do
NOT require the adb_command opt-in (a raw shell door and a registered intent are
different consents).
Verify: 868 passed / 4 skipped (was 794+4 before this ticket's tests — the new
tests are additive), ruff clean, both strings files byte-identical.
This commit is contained in:
1 parent
a8041e1428
commit
259a6c29bd
7 files changed
+2715
-27
No files matched your search
@@ -1844,36 +1844,87 @@ class TestIconOverride:
|
||||
|
||||
class TestServiceRegistryKey:
|
||||
"""t26 F4 + t29 G8: the participant key is computed once — the registry
|
||||
add/remove always match (the service itself stays registered)."""
|
||||
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
|
||||
t17: these drive the REAL hooks instead of hand-writing the registry. The
|
||||
hand-written version passed while ``async_added_to_hass`` registered ``None``
|
||||
for a real entry (it filled the registry before consuming the entry's
|
||||
manager) and while ``async_will_remove_from_hass`` never dropped the key at
|
||||
all — both invisible to a test that pokes the dict itself.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _real_player(modules):
|
||||
"""A player wired the way a real entry is: the manager on runtime_data."""
|
||||
mods = load_entity_modules()
|
||||
player, _s, _i = make_player(mods)
|
||||
manager = player._manager
|
||||
# exactly what __init__.py puts on the entry before the platforms load
|
||||
player._entry.runtime_data = manager
|
||||
player._entry.options["adb_command_enabled"] = True
|
||||
player._attr_unique_id = "uid_late"
|
||||
# the harness constructs the entity without __init__, so the list the
|
||||
# hook appends its tracked listeners to has to exist (`_power_entity` and
|
||||
# `_jellyfin_entity` are read-only properties over the entry's options —
|
||||
# with neither configured they are None, which is the real empty state)
|
||||
player._power_sensors = {}
|
||||
player._unsub_listeners = []
|
||||
player._manager_unsub = None
|
||||
# no entity_id yet at registration (HA core assigns it right after)
|
||||
player.hass = entity_stublib.FakeHass()
|
||||
player.hass.services = FakeServices()
|
||||
if hasattr(player, "_adb_command_key"):
|
||||
del player._adb_command_key # start clean
|
||||
return player, manager
|
||||
|
||||
# add path — the key is computed once from unique_id
|
||||
key = getattr(player, "entity_id", None) or player._attr_unique_id
|
||||
player._adb_command_key = key
|
||||
_ADB_COMMAND_TARGETS[key] = player._manager
|
||||
assert "uid_late" in _ADB_COMMAND_TARGETS
|
||||
async def test_late_entity_id_assignment_still_removes(self, modules):
|
||||
from nsh.media_player import _ADB_COMMAND_TARGETS, _INTENT_TARGETS
|
||||
|
||||
player, manager = self._real_player(modules)
|
||||
for registry in (_ADB_COMMAND_TARGETS, _INTENT_TARGETS):
|
||||
registry.pop("uid_late", None)
|
||||
|
||||
await player.async_added_to_hass()
|
||||
|
||||
assert _ADB_COMMAND_TARGETS["uid_late"] is manager, "the KEY is the unique_id"
|
||||
assert _INTENT_TARGETS["uid_late"] is manager
|
||||
|
||||
# HA core assigned the entity_id afterwards — removal still matches
|
||||
player.entity_id = "media_player.shield_test"
|
||||
|
||||
# drop participation via the stored key (entity_id is NOT consulted)
|
||||
_ADB_COMMAND_TARGETS.pop(player._adb_command_key)
|
||||
assert "uid_late" not in _ADB_COMMAND_TARGETS
|
||||
await player.async_will_remove_from_hass()
|
||||
|
||||
assert "uid_late" not in _ADB_COMMAND_TARGETS, "the add key, not entity_id"
|
||||
assert "media_player.shield_test" not in _ADB_COMMAND_TARGETS
|
||||
assert "uid_late" not in _INTENT_TARGETS, "both registries are dropped"
|
||||
assert "media_player.shield_test" not in _INTENT_TARGETS
|
||||
|
||||
async def test_the_hook_registers_the_entry_s_own_manager(self, modules):
|
||||
"""The manager comes from runtime_data — never None (t17 latent fix)."""
|
||||
player, _manager_ignored = self._real_player(modules)
|
||||
fresh = make_manager()
|
||||
player._manager = None # __init__ has not run in this harness
|
||||
player._entry.runtime_data = fresh
|
||||
|
||||
await player.async_added_to_hass()
|
||||
|
||||
from nsh.media_player import _ADB_COMMAND_TARGETS
|
||||
|
||||
assert _ADB_COMMAND_TARGETS["uid_late"] is fresh
|
||||
_ADB_COMMAND_TARGETS.pop("uid_late", None)
|
||||
|
||||
async def test_intents_do_not_need_the_adb_command_opt_in(self, modules):
|
||||
"""A registered intent is the user's own opt-in (t17)."""
|
||||
from nsh.media_player import _ADB_COMMAND_TARGETS, _INTENT_TARGETS
|
||||
|
||||
player, manager = self._real_player(modules)
|
||||
player._entry.options["adb_command_enabled"] = False # the raw door is off
|
||||
for registry in (_ADB_COMMAND_TARGETS, _INTENT_TARGETS):
|
||||
registry.pop("uid_late", None)
|
||||
|
||||
await player.async_added_to_hass()
|
||||
|
||||
assert "uid_late" not in _ADB_COMMAND_TARGETS
|
||||
assert _INTENT_TARGETS["uid_late"] is manager
|
||||
_INTENT_TARGETS.pop("uid_late", None)
|
||||
|
||||
async def test_setup_entry_object_carries_the_key(self):
|
||||
"""The participant object stores the key F4 computed (both hooks)."""
|
||||
|
||||
@@ -1628,12 +1628,27 @@ OPTIONS_NESTED_STEPS = {
|
||||
"apps_edit_form": "apps",
|
||||
"apps_remove": "apps",
|
||||
"apps_remove_confirm": "apps",
|
||||
# t17: the Intents editor is the same shape — a menu, then one row per step
|
||||
"intents_edit": "intents",
|
||||
"intents_form": "intents",
|
||||
"intents_remove": "intents",
|
||||
"intents_remove_confirm": "intents",
|
||||
"intents_scan": "intents",
|
||||
"intents_scan_result": "intents",
|
||||
}
|
||||
|
||||
#: Menu rows that ACT rather than render a form: HA dispatches them with no input,
|
||||
#: so there is no data_schema and no field labels to check.
|
||||
OPTIONS_ACTION_ROWS = frozenset(
|
||||
{"discover", "finish", "menu_again", "apps_remove_yes", "apps_remove_no"}
|
||||
{
|
||||
"discover",
|
||||
"finish",
|
||||
"menu_again",
|
||||
"apps_remove_yes",
|
||||
"apps_remove_no",
|
||||
"intents_remove_yes",
|
||||
"intents_remove_no",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -2066,6 +2081,7 @@ class TestOptionsMenu:
|
||||
"connection",
|
||||
"receiver",
|
||||
"apps",
|
||||
"intents",
|
||||
"discover",
|
||||
"scripts",
|
||||
"finish",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user