The t18 review's three findings, closed with the reviewer's own repro.
F1 (HIGH — the harm was real shells, not a wrong label). `async_scan_intents`
decided between `adb_disabled` and `adb_unavailable` INSIDE `if self._poller is
None:`, so a manager that HELD a poller fell through to the shell loop: with
`adb_enabled: False` and a connected poller the review measured status `fresh`,
one candidate CACHED, and `pm dump com.x …` really sent. The option is a fact
about the ENTRY, so it is now checked once at the top and returns before
anything else — no cache read, no cache write, no shell. The no-poller branch
keeps the OTHER honest answer: with the option ON and nobody to ask, the status
is `adb_unavailable` ("wait or reconnect"), never `adb_disabled` ("go turn the
option on"), because the two have different fixes. Five tests pin it, including
a cache-leak case (a manager with a cached answer whose option is then switched
off answers `adb_disabled`, not the cache) and both statuses with a FORCED scan.
F2 (low, but it fabricated a value that looked real). A capture cut mid-detail
produced `Scheme: "ht` → scheme `"ht` and `Authority: "www.exam` → that
authority, and `_candidate_data` would then prefill the form with a URL no app
can match. Not an injection (everything stays quoted when used), which is
exactly why nothing caught it. `_unquote` now DROPS a value that opens a quote
and never closes it (a lone quote too), so the callers turn it into None: the
candidate survives with its intact action/component and simply carries no
scheme/authority/path — and therefore prefills NO data instead of a broken URL.
A complete quoted value is still read (the anti-overcorrection pin), and the
consequence is pinned at the consumption end too, through the real options-flow
helper.
F3 (low). The floor comment now answers the review in writing, where the next
reader looks: the t18 review's measurement (awaited_calls 6 against a floor of 5,
three other counters within 2) is named, and the verdict is recorded — those ARE
the policy margins (2 for owner-counters because an owner really can be
refactored away, 1 for awaited_calls because six sites cannot carry a percentage
band). The counters were genuinely updated, not dodged: raised to the real sweep
when the code grew (20→24, 70→75, 235→275, 55→63, 1400→1620) and re-derived
twice since (t20 1620→1615, t21 1615→1617 for this unit's blocks).
EVIDENCE THAT THE TESTS PIN THE FIX (not merely pass): the same files were run
against the PRE-fix source (git archive HEAD, a throwaway copy under /tmp) — 9 of
the 11 new tests FAIL there, including the reviewer's repro, which fails with
`assert 'fresh' == 'adb_disabled'`. The two that pass are the deliberate
anti-overcorrection pins: a COMPLETE quoted value is still parsed, and the
no-poller control keeps its own status.
NO REGRESSION: 900 passed / 5 skipped, 901 / 4 with androidtvremote2; the
injection proof, the fixture's candidates with dedup/flagging/order/bound
disclosures, both cache outcomes, the registration round trip through the real
FlowManager, button lifecycle and defaults, the YAML seed rule and §7.1
(supports_response optional, a real return_response call, a no-response call) all
still pass. The strings files are byte-identical (untouched here). Real-HA pattern
collected and RAN 3 files → 74 passed / 0 skipped. Drift exit 0 (1650 blocks).
NOT VERIFIED HERE: the device side remains a scripted double — no real Shield was
asked for its resolver table, so the shell COUNT and the status vocabulary are
what is proven, not real firmware behaviour. No browser either: the form prefill
is asserted on the values the helper returns.
1373 lines
54 KiB
Python
1373 lines
54 KiB
Python
"""tests/test_intents_registration.py — t17: the registered-intent feature.
|
|
|
|
Four things are pinned here, in the order the user meets them:
|
|
|
|
1. THE MODEL AND THE COMMAND (`media_player.py`): what a registered intent is,
|
|
that an unusable row never becomes a button, and — the part that matters —
|
|
that the ``am start`` command is BUILT from validated data with plain text
|
|
substitution and quoted values, so nothing a variable contains can become
|
|
shell syntax.
|
|
2. THE SCREEN (`config_flow.py`): the Intents sub-menu, the selector with its
|
|
Add-new sentinel, the per-intent form, removal, and the scan that offers a
|
|
discovered candidate — plus the seed validator, which must produce a row
|
|
IDENTICAL to one typed into the form.
|
|
3. THE BUTTON (`button.py`): one entity per registration, sending the declared
|
|
defaults, failing loudly (the uninstalled-app case is the device's own
|
|
message, not a swallowed no-op).
|
|
4. THE ACTION (`media_player._async_send_intent_service`): the intent lookup,
|
|
the variables, and the output returned under ``intent_response``.
|
|
|
|
The flow and the entity modules are loaded in ONE stub environment on purpose:
|
|
point 1's model exists twice (the runtime's reader and the screen's), and the
|
|
test at the bottom of ``TestTheModel`` drives both over the same rows so they
|
|
cannot drift into meaning different things.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import importlib
|
|
import shlex
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import entity_stublib # noqa: E402
|
|
from entity_stublib import load_entity_modules # noqa: E402
|
|
from nsh.const import ( # noqa: E402
|
|
CONF_REGISTERED_INTENTS,
|
|
INTENT_FIELD_ACTION,
|
|
INTENT_FIELD_ACTIVITY,
|
|
INTENT_FIELD_CANDIDATE,
|
|
INTENT_FIELD_CATEGORIES,
|
|
INTENT_FIELD_CHOICE,
|
|
INTENT_FIELD_DATA,
|
|
INTENT_FIELD_DECLARED,
|
|
INTENT_FIELD_LIMIT,
|
|
INTENT_FIELD_NAME,
|
|
INTENT_FIELD_PACKAGE,
|
|
INTENT_FIELD_SCAN_PACKAGE,
|
|
INTENT_NEW_SENTINEL,
|
|
INTENT_REGISTRATION_LIMIT,
|
|
INTENT_RESPONSE_KEY,
|
|
)
|
|
from test_standalone_setup import FakeHass, _form_module, _install_stubs # noqa: E402
|
|
|
|
#: A registration as the plan describes it: a component, a data template with a
|
|
#: declared placeholder, and a required field the caller must supply.
|
|
NETFLIX_INTENT = {
|
|
"id": "netflix_title",
|
|
"name": "Netflix: open a title",
|
|
"package": "com.netflix.ninja",
|
|
"activity": "com.netflix.ninja/.MainActivity",
|
|
"action": "android.intent.action.VIEW",
|
|
"categories": ["android.intent.category.DEFAULT"],
|
|
"data": "https://www.netflix.com/watch/{id}",
|
|
"fields": [{"key": "id", "type": "string", "required": True}],
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def scoped_stubs():
|
|
"""Keep the import stubs and the loaded modules inside this file (t7)."""
|
|
snapshot = entity_stublib.snapshot_modules()
|
|
_install_stubs()
|
|
yield
|
|
entity_stublib.restore_modules(snapshot)
|
|
|
|
|
|
@pytest.fixture()
|
|
def modules(tmp_path):
|
|
"""The flow module AND the entity modules, in one environment."""
|
|
flow = _form_module()
|
|
media = load_entity_modules()
|
|
return SimpleNamespace(flow=flow, media=media["media_player"])
|
|
|
|
|
|
class _RecordingPoller:
|
|
"""A poller that answers with what a test puts on the wire."""
|
|
|
|
def __init__(self, answer='Starting: Intent { act=android.intent.action.VIEW }'):
|
|
self.answer = answer
|
|
self.commands: list[str] = []
|
|
|
|
async def shell(self, command):
|
|
self.commands.append(command)
|
|
if isinstance(self.answer, Exception):
|
|
raise self.answer
|
|
return self.answer
|
|
|
|
|
|
def _manager(poller, *, connected=True):
|
|
return SimpleNamespace(adb_poller=poller, connected=connected, entry=None)
|
|
|
|
|
|
# ── 1. the model and the command ───────────────────────────────────────────
|
|
|
|
|
|
class TestTheCommand:
|
|
"""The `am start` command: built, quoted, and never evaluated."""
|
|
|
|
def test_the_plan_s_mapping_is_exactly_what_is_sent(self, modules):
|
|
command = modules.media.intent_am_start_command(
|
|
NETFLIX_INTENT, {"id": "80100172"}
|
|
)
|
|
|
|
assert command == (
|
|
"am start -n com.netflix.ninja/.MainActivity "
|
|
"-a android.intent.action.VIEW -c android.intent.category.DEFAULT "
|
|
"-d https://www.netflix.com/watch/80100172 --es id 80100172"
|
|
)
|
|
|
|
def test_every_declared_type_becomes_its_own_flag(self, modules):
|
|
intent = {
|
|
**NETFLIX_INTENT,
|
|
"data": "",
|
|
"fields": [
|
|
{"key": "title", "type": "string", "required": True},
|
|
{"key": "season", "type": "int", "required": True},
|
|
{"key": "resume", "type": "bool", "required": True},
|
|
{"key": "extra", "type": "raw", "required": True},
|
|
],
|
|
}
|
|
|
|
command = modules.media.intent_am_start_command(
|
|
intent,
|
|
{"title": "Sky", "season": "2", "resume": "YES", "extra": "--user 0"},
|
|
)
|
|
|
|
assert "--es title Sky" in command
|
|
assert "--ei season 2" in command
|
|
assert "--ez resume true" in command # normalised to the device's boolean
|
|
assert "--es extra '--user 0'" in command, "raw is quoted like everything else"
|
|
|
|
def test_a_hostile_variable_is_one_literal_argument(self, modules):
|
|
hostile = '"; rm -rf /"'
|
|
command = modules.media.intent_am_start_command(
|
|
{**NETFLIX_INTENT, "data": ""}, {"id": hostile}
|
|
)
|
|
|
|
tokens = shlex.split(command)
|
|
|
|
# the shell sees the value as ONE argument, exactly as typed
|
|
assert tokens == ["am", "start", "-n", NETFLIX_INTENT["activity"],
|
|
"-a", NETFLIX_INTENT["action"],
|
|
"-c", "android.intent.category.DEFAULT",
|
|
"--es", "id", hostile]
|
|
assert "rm -rf" not in command.split("--es")[0]
|
|
|
|
def test_a_hostile_data_value_is_quoted_too(self, modules):
|
|
command = modules.media.intent_am_start_command(
|
|
NETFLIX_INTENT, {"id": "a; reboot"}
|
|
)
|
|
|
|
assert shlex.split(command)[-1] == "a; reboot"
|
|
assert "https://www.netflix.com/watch/a; reboot" in shlex.split(command)
|
|
|
|
def test_substitution_is_plain_text_and_runs_once(self, modules):
|
|
"""A value that LOOKS like a placeholder is data, never re-substituted."""
|
|
intent = {
|
|
**NETFLIX_INTENT,
|
|
"data": "https://example.test/{id}",
|
|
"fields": [{"key": "id", "type": "string", "required": True}],
|
|
}
|
|
|
|
command = modules.media.intent_am_start_command(intent, {"id": "{name}"})
|
|
|
|
assert "https://example.test/{name}" in command
|
|
assert shlex.split(command)[-1] == "{name}"
|
|
|
|
def test_attribute_style_placeholders_are_left_alone(self, modules):
|
|
"""No eval, no format(): ``{id.__class__}`` is not a placeholder at all."""
|
|
intent = {**NETFLIX_INTENT, "data": "https://example.test/{id.__class__}"}
|
|
|
|
command = modules.media.intent_am_start_command(intent, {"id": "1"})
|
|
|
|
assert "https://example.test/{id.__class__}" in command
|
|
|
|
def test_a_missing_required_field_names_the_field(self, modules):
|
|
err = pytest.raises(
|
|
modules.media.ServiceValidationError if hasattr(modules.media, "ServiceValidationError")
|
|
else Exception
|
|
)
|
|
with err as caught:
|
|
modules.media.intent_am_start_command(NETFLIX_INTENT, {})
|
|
|
|
assert caught.value.translation_key == "missing_intent_field"
|
|
assert caught.value.translation_placeholders["field"] == "id"
|
|
assert "id" in str(caught.value)
|
|
|
|
def test_an_undeclared_variable_is_refused_not_dropped(self, modules):
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
modules.media.intent_am_start_command(
|
|
NETFLIX_INTENT, {"id": "1", "sneaky": "x"}
|
|
)
|
|
|
|
assert caught.value.translation_key == "unknown_intent_variable"
|
|
assert caught.value.translation_placeholders["fields"] == "sneaky"
|
|
|
|
def test_a_value_that_cannot_become_its_type_names_field_and_value(self, modules):
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
intent = {
|
|
**NETFLIX_INTENT,
|
|
"data": "",
|
|
"fields": [{"key": "season", "type": "int", "required": True}],
|
|
}
|
|
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
modules.media.intent_am_start_command(intent, {"season": "winter"})
|
|
|
|
assert caught.value.translation_key == "invalid_intent_field"
|
|
assert caught.value.translation_placeholders["field"] == "season"
|
|
assert "winter" in str(caught.value)
|
|
|
|
def test_an_unfilled_placeholder_raises_instead_of_sending_braces(self, modules):
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
intent = {**NETFLIX_INTENT, "fields": []} # nothing declares {id}
|
|
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
modules.media.intent_am_start_command(intent, {})
|
|
|
|
assert caught.value.translation_key == "unresolved_intent_placeholder"
|
|
assert caught.value.translation_placeholders["placeholder"] == "id"
|
|
|
|
def test_defaults_satisfy_the_template(self, modules):
|
|
intent = {
|
|
**NETFLIX_INTENT,
|
|
"fields": [{"key": "id", "type": "string", "default": "42"}],
|
|
}
|
|
|
|
assert "watch/42" in modules.media.intent_am_start_command(intent, {})
|
|
assert modules.media.intent_default_variables(intent) == {"id": "42"}
|
|
|
|
def test_an_optional_field_without_a_value_is_simply_not_sent(self, modules):
|
|
intent = {
|
|
**NETFLIX_INTENT,
|
|
"data": "",
|
|
"fields": [{"key": "quality", "type": "string"}],
|
|
}
|
|
|
|
command = modules.media.intent_am_start_command(intent, {})
|
|
|
|
assert "--es" not in command
|
|
assert "quality" not in command
|
|
|
|
|
|
class TestRunningAnIntent:
|
|
"""What actually reaches the device, and what happens when it refuses."""
|
|
|
|
async def test_the_device_gets_exactly_one_built_command(self, modules):
|
|
poller = _RecordingPoller()
|
|
manager = _manager(poller)
|
|
|
|
output = await modules.media.run_registered_intent(
|
|
manager, "media_player.shield", NETFLIX_INTENT, {"id": "1"}
|
|
)
|
|
|
|
assert output.startswith("Starting: Intent")
|
|
assert poller.commands == [
|
|
modules.media.intent_am_start_command(NETFLIX_INTENT, {"id": "1"})
|
|
]
|
|
|
|
async def test_adb_down_says_the_protocol_cannot_carry_an_intent(self, modules):
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
for manager in (_manager(None), _manager(_RecordingPoller(), connected=False)):
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
await modules.media.run_registered_intent(
|
|
manager, "media_player.shield", NETFLIX_INTENT, {"id": "1"}
|
|
)
|
|
assert caught.value.translation_key == "intents_need_adb"
|
|
assert "protocol" in str(caught.value)
|
|
|
|
async def test_a_transport_that_never_answered_is_an_error(self, modules):
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
await modules.media.run_registered_intent(
|
|
_manager(_RecordingPoller(None)),
|
|
"media_player.shield",
|
|
NETFLIX_INTENT,
|
|
{"id": "1"},
|
|
)
|
|
|
|
assert caught.value.translation_key == "intent_no_answer"
|
|
|
|
async def test_a_refused_intent_keeps_the_device_s_own_words(self, modules):
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
refusal = (
|
|
"Starting: Intent { act=android.intent.action.VIEW }\n"
|
|
"Error: Activity class {com.netflix.ninja/.MainActivity} does not exist."
|
|
)
|
|
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
await modules.media.run_registered_intent(
|
|
_manager(_RecordingPoller(refusal)),
|
|
"media_player.shield",
|
|
NETFLIX_INTENT,
|
|
{"id": "1"},
|
|
)
|
|
|
|
assert caught.value.translation_key == "intent_failed"
|
|
assert "does not exist" in str(caught.value)
|
|
assert "com.netflix.ninja/.MainActivity" in str(caught.value)
|
|
|
|
def test_the_failure_detector_knows_a_success_from_a_refusal(self, modules):
|
|
detect = modules.media.intent_failure_message
|
|
|
|
assert detect("Starting: Intent { cmp=com.a/.B }") is None
|
|
assert detect(None) is None
|
|
assert "unable to resolve" in detect(
|
|
"Error: Unable to resolve Intent { act=x }"
|
|
).lower()
|
|
assert detect("Permission Denial: starting Intent requires permission") is not None
|
|
|
|
|
|
# ── 2. the model as READ, and the screen's copy of it ──────────────────────
|
|
|
|
|
|
class TestTheModel:
|
|
"""What counts as a usable registration, and the two readers agreeing."""
|
|
|
|
def test_a_row_that_names_nothing_to_launch_is_dropped(self, modules):
|
|
rows = modules.media.registered_intents(
|
|
{
|
|
CONF_REGISTERED_INTENTS: [
|
|
NETFLIX_INTENT,
|
|
{"id": "broken", "name": "Broken", "package": "com.x"},
|
|
{"id": "nolabel", "package": "com.x", "action": "a.B"},
|
|
]
|
|
}
|
|
)
|
|
|
|
assert [row["id"] for row in rows] == ["netflix_title"]
|
|
|
|
def test_categories_and_fields_are_normalised(self, modules):
|
|
rows = modules.media.registered_intents(
|
|
{
|
|
CONF_REGISTERED_INTENTS: [
|
|
{
|
|
**NETFLIX_INTENT,
|
|
"categories": ["", " android.intent.category.DEFAULT "],
|
|
"fields": [
|
|
{"key": "id", "type": "STRING", "required": 1},
|
|
{"key": "9bad", "type": "string"},
|
|
{"key": "ok", "type": "nope"},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
assert rows[0]["categories"] == ["android.intent.category.DEFAULT"]
|
|
assert rows[0]["fields"] == [{"key": "id", "type": "string", "required": True}]
|
|
|
|
def test_duplicate_ids_keep_the_first_and_the_cap_holds(self, modules):
|
|
rows = modules.media.registered_intents(
|
|
{
|
|
CONF_REGISTERED_INTENTS: [
|
|
{**NETFLIX_INTENT, "name": "First"},
|
|
{**NETFLIX_INTENT, "name": "Second"},
|
|
]
|
|
+ [
|
|
{**NETFLIX_INTENT, "id": f"i{n}", "name": f"n{n}"}
|
|
for n in range(INTENT_REGISTRATION_LIMIT + 5)
|
|
]
|
|
}
|
|
)
|
|
|
|
assert rows[0]["name"] == "First"
|
|
assert len(rows) == INTENT_REGISTRATION_LIMIT
|
|
|
|
def test_a_mapping_shaped_blob_still_reads(self, modules):
|
|
rows = modules.media.registered_intents(
|
|
{CONF_REGISTERED_INTENTS: {"netflix_title": NETFLIX_INTENT}}
|
|
)
|
|
|
|
assert [row["id"] for row in rows] == ["netflix_title"]
|
|
|
|
def test_lookup_by_id_and_by_name(self, modules):
|
|
options = {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}
|
|
|
|
assert modules.media.intent_by_id(options, "netflix_title")["name"] == (
|
|
NETFLIX_INTENT["name"]
|
|
)
|
|
assert modules.media.intent_by_id(options, NETFLIX_INTENT["name"]) is not None
|
|
assert modules.media.intent_by_id(options, "nope") is None
|
|
|
|
def test_the_screen_and_the_runtime_read_the_same_rows(self, modules):
|
|
"""The anti-drift pin: the flow's reader and the runtime's must agree.
|
|
|
|
They live in different modules because the options flow must stay
|
|
importable without Home Assistant's media_player platform. This drives
|
|
both over one battery — valid rows, junk, a duplicate id, an over-long
|
|
list, a mapping-shaped blob — and requires the same ids, names, packages,
|
|
components, actions, categories and fields from each.
|
|
"""
|
|
battery = [
|
|
NETFLIX_INTENT,
|
|
{**NETFLIX_INTENT, "id": "second"},
|
|
{"id": "junk", "name": "Junk", "package": "com.x"},
|
|
"not-a-dict",
|
|
{**NETFLIX_INTENT},
|
|
{**NETFLIX_INTENT, "activity": "no-slash", "action": "a.B"},
|
|
{**NETFLIX_INTENT, "id": "fields", "fields": [{"key": "bad key"}]},
|
|
]
|
|
|
|
runtime = modules.media.registered_intents({CONF_REGISTERED_INTENTS: battery})
|
|
screen = modules.flow.registered_intent_rows({CONF_REGISTERED_INTENTS: battery})
|
|
|
|
assert runtime == screen
|
|
|
|
|
|
# ── 3. the screen ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _flow(modules, tmp_path, options=None):
|
|
flow = modules.flow.NvidiaShieldOptionsFlow()
|
|
flow.config_entry = SimpleNamespace(
|
|
options=dict(options or {}), entry_id="e1", title="Shield"
|
|
)
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
|
flow.async_show_menu = lambda **kwargs: {"type": "menu", **kwargs}
|
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
|
return flow
|
|
|
|
|
|
def _form_payload(**overrides):
|
|
payload = {
|
|
INTENT_FIELD_NAME: NETFLIX_INTENT["name"],
|
|
INTENT_FIELD_PACKAGE: NETFLIX_INTENT["package"],
|
|
INTENT_FIELD_ACTIVITY: NETFLIX_INTENT["activity"],
|
|
INTENT_FIELD_ACTION: NETFLIX_INTENT["action"],
|
|
INTENT_FIELD_CATEGORIES: "android.intent.category.DEFAULT",
|
|
INTENT_FIELD_DATA: NETFLIX_INTENT["data"],
|
|
INTENT_FIELD_DECLARED: "id:string:required",
|
|
}
|
|
payload.update(overrides)
|
|
return payload
|
|
|
|
|
|
class TestTheIntentsScreen:
|
|
"""The menu, the sentinel, the form, removal — and nothing half-stored."""
|
|
|
|
async def test_the_menu_offers_the_screen_and_labels_it(self, modules, tmp_path):
|
|
flow = _flow(modules, tmp_path)
|
|
|
|
menu = await flow.async_step_intents(None)
|
|
|
|
assert menu["type"] == "menu"
|
|
assert list(menu["menu_options"]) == ["intents_edit", "intents_scan", "menu_again"]
|
|
assert "no intents registered yet" in menu["description_placeholders"]["intents"]
|
|
|
|
async def test_remove_appears_only_with_something_to_remove(self, modules, tmp_path):
|
|
flow = _flow(modules, tmp_path)
|
|
|
|
menu = await flow.async_step_intents(None)
|
|
assert "intents_remove" not in menu["menu_options"]
|
|
|
|
flow._options_dict[CONF_REGISTERED_INTENTS] = [NETFLIX_INTENT]
|
|
menu = await flow.async_step_intents(None)
|
|
|
|
assert "intents_remove" in menu["menu_options"]
|
|
assert "1 intent registered" in menu["description_placeholders"]["intents"]
|
|
|
|
async def test_the_selector_lists_every_intent_and_the_add_new_sentinel(
|
|
self, modules, tmp_path
|
|
):
|
|
flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
|
|
|
result = await flow.async_step_intents_edit(None)
|
|
|
|
assert result["type"] == "form" and result["step_id"] == "intents_edit"
|
|
# the selector is the field the form declares; its options carry the
|
|
# registered intent AND the explicit Add-new row
|
|
choices = flow._intent_choices()
|
|
assert [row["value"] for row in choices] == ["netflix_title", INTENT_NEW_SENTINEL]
|
|
assert choices[-1]["label"].startswith("+ Add a new intent")
|
|
|
|
async def test_add_new_opens_a_blank_form(self, modules, tmp_path):
|
|
flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
|
|
|
form = await flow.async_step_intents_edit(
|
|
{INTENT_FIELD_CHOICE: INTENT_NEW_SENTINEL}
|
|
)
|
|
|
|
assert form["type"] == "form" and form["step_id"] == "intents_form"
|
|
assert "new intent" in form["description_placeholders"]["intent_note"]
|
|
|
|
async def test_an_unknown_choice_is_refused(self, modules, tmp_path):
|
|
flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
|
|
|
result = await flow.async_step_intents_edit({INTENT_FIELD_CHOICE: "ghost"})
|
|
|
|
assert result["errors"] == {"base": "unknown_intent_row"}
|
|
|
|
async def test_a_new_intent_is_stored_with_a_slug_id(self, modules, tmp_path):
|
|
flow = _flow(modules, tmp_path)
|
|
|
|
result = await flow.async_step_intents_form(_form_payload())
|
|
|
|
assert result["type"] == "menu" # back at the Intents editor
|
|
stored = flow._options_dict[CONF_REGISTERED_INTENTS]
|
|
assert len(stored) == 1
|
|
assert stored[0]["id"] == "netflix_open_a_title"
|
|
assert stored[0]["fields"] == [{"key": "id", "type": "string", "required": True}]
|
|
assert stored[0]["categories"] == ["android.intent.category.DEFAULT"]
|
|
|
|
async def test_stored_rows_are_json_safe(self, modules, tmp_path):
|
|
"""Options are persisted as JSON: a set or a tuple here would break storage."""
|
|
import json
|
|
|
|
flow = _flow(modules, tmp_path)
|
|
await flow.async_step_intents_form(_form_payload())
|
|
|
|
json.dumps(flow._options_dict) # raises if a row is not serialisable
|
|
|
|
async def test_editing_one_row_leaves_the_others_alone(self, modules, tmp_path):
|
|
other = {**NETFLIX_INTENT, "id": "other", "name": "Another"}
|
|
flow = _flow(
|
|
modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT, other]}
|
|
)
|
|
|
|
result = await flow.async_step_intents_edit(
|
|
{INTENT_FIELD_CHOICE: "netflix_title"}
|
|
)
|
|
assert result["step_id"] == "intents_form"
|
|
assert result["description_placeholders"]["intent_note"].startswith("editing ")
|
|
|
|
await flow.async_step_intents_form(
|
|
_form_payload(**{INTENT_FIELD_NAME: "Renamed"})
|
|
)
|
|
|
|
stored = flow._options_dict[CONF_REGISTERED_INTENTS]
|
|
assert [row["id"] for row in stored] == ["netflix_title", "other"]
|
|
assert stored[0]["name"] == "Renamed" # the rename landed on THAT row
|
|
assert stored[1] == other # …and this one is byte-identical to before
|
|
|
|
async def test_a_refused_submission_stores_nothing_and_says_why(
|
|
self, modules, tmp_path
|
|
):
|
|
flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
|
|
|
result = await flow.async_step_intents_form(
|
|
_form_payload(
|
|
**{
|
|
INTENT_FIELD_NAME: "Nothing to launch",
|
|
INTENT_FIELD_ACTIVITY: "",
|
|
INTENT_FIELD_ACTION: "",
|
|
}
|
|
)
|
|
)
|
|
|
|
assert result["type"] == "form"
|
|
assert result["errors"] == {"base": "invalid_intent_row"}
|
|
note = result["description_placeholders"]["intent_note"]
|
|
assert "component" in note and "action" in note
|
|
assert len(flow._options_dict[CONF_REGISTERED_INTENTS]) == 1 # unchanged
|
|
|
|
async def test_each_refusal_names_its_own_problem(self, modules, tmp_path):
|
|
cases = [
|
|
({INTENT_FIELD_PACKAGE: "has spaces"}, "not a usable package id"),
|
|
({INTENT_FIELD_ACTIVITY: "com.x.MainActivity"}, "not a component"),
|
|
({INTENT_FIELD_DATA: "https://x/{nope}"}, "no declared field"),
|
|
({INTENT_FIELD_DECLARED: "id:weird"}, "is not one of"),
|
|
({INTENT_FIELD_NAME: NETFLIX_INTENT["name"]}, "already called"),
|
|
]
|
|
|
|
for overrides, expected in cases:
|
|
flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
|
payload = _form_payload(**overrides)
|
|
if INTENT_FIELD_NAME in overrides:
|
|
payload[INTENT_FIELD_CHOICE] = INTENT_NEW_SENTINEL
|
|
else:
|
|
flow._options_dict[CONF_REGISTERED_INTENTS] = [NETFLIX_INTENT]
|
|
payload[INTENT_FIELD_NAME] = "New name"
|
|
result = await flow.async_step_intents_form(payload)
|
|
|
|
assert result["errors"] == {"base": "invalid_intent_row"}, overrides
|
|
assert expected in result["description_placeholders"]["intent_note"], overrides
|
|
|
|
async def test_removal_takes_a_confirmation_and_drops_only_that_row(
|
|
self, modules, tmp_path
|
|
):
|
|
other = {**NETFLIX_INTENT, "id": "other", "name": "Another"}
|
|
flow = _flow(
|
|
modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT, other]}
|
|
)
|
|
|
|
confirm = await flow.async_step_intents_remove(
|
|
{INTENT_FIELD_CHOICE: "netflix_title"}
|
|
)
|
|
assert confirm["step_id"] == "intents_remove_confirm"
|
|
assert confirm["description_placeholders"]["intent"] == (
|
|
"Netflix: open a title (com.netflix.ninja)"
|
|
)
|
|
assert confirm["description_placeholders"]["button"] == NETFLIX_INTENT["name"]
|
|
assert "intents_remove_yes" in confirm["menu_options"], "it takes a second act"
|
|
|
|
# nothing is dropped until the user confirms
|
|
assert len(flow._options_dict[CONF_REGISTERED_INTENTS]) == 2
|
|
|
|
await flow.async_step_intents_remove_yes(None)
|
|
|
|
stored = flow._options_dict[CONF_REGISTERED_INTENTS]
|
|
assert [row["id"] for row in stored] == ["other"]
|
|
|
|
async def test_cancelling_a_removal_changes_nothing(self, modules, tmp_path):
|
|
flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
|
await flow.async_step_intents_remove({INTENT_FIELD_CHOICE: "netflix_title"})
|
|
|
|
result = await flow.async_step_intents_remove_no(None)
|
|
|
|
assert result["step_id"] == "intents"
|
|
assert flow._options_dict[CONF_REGISTERED_INTENTS] == [NETFLIX_INTENT]
|
|
|
|
async def test_a_registration_survives_a_new_flow_on_the_same_options(
|
|
self, modules, tmp_path
|
|
):
|
|
"""The options ARE the store: a fresh flow reads what the last one wrote."""
|
|
options = {}
|
|
first = _flow(modules, tmp_path, options)
|
|
await first.async_step_intents_form(_form_payload())
|
|
options.update(first._options_dict)
|
|
|
|
second = _flow(modules, tmp_path, options)
|
|
|
|
assert [row["id"] for row in second._registered_intents()] == [
|
|
"netflix_open_a_title"
|
|
]
|
|
|
|
|
|
class TestTheDeclaredFieldsText:
|
|
"""The `key:type[:required][=value]` grammar, one line at a time."""
|
|
|
|
def test_the_grammar_reads_every_shape(self, modules):
|
|
fields, problems = modules.flow.intent_fields_from_text(
|
|
"# a comment\nid:string:required\nseason:int:required\n"
|
|
"quality:string=high\nresume:bool\nblob:raw\n"
|
|
)
|
|
|
|
assert problems == []
|
|
assert fields == [
|
|
{"key": "id", "type": "string", "required": True},
|
|
{"key": "season", "type": "int", "required": True},
|
|
{"key": "quality", "type": "string", "required": False, "default": "high"},
|
|
{"key": "resume", "type": "bool", "required": False},
|
|
{"key": "blob", "type": "raw", "required": False},
|
|
]
|
|
|
|
def test_every_problem_names_its_line(self, modules):
|
|
fields, problems = modules.flow.intent_fields_from_text(
|
|
"id:string\nnoline\n9bad:string\nid:string:maybe\nboth:int:required=1\nid:string\n"
|
|
)
|
|
|
|
assert fields is None
|
|
assert [problem.split(":")[0] for problem in problems] == [
|
|
"fields line 2",
|
|
"fields line 3",
|
|
"fields line 4",
|
|
"fields line 5",
|
|
"fields line 6",
|
|
]
|
|
|
|
def test_the_text_round_trips_through_the_form(self, modules):
|
|
fields = [
|
|
{"key": "id", "type": "string", "required": True},
|
|
{"key": "quality", "type": "string", "required": False, "default": "high"},
|
|
]
|
|
|
|
text = modules.flow.intent_fields_to_text(fields)
|
|
|
|
assert text == "id:string:required\nquality:string=high"
|
|
assert modules.flow.intent_fields_from_text(text)[0] == fields
|
|
|
|
def test_the_cap_is_refused_rather_than_truncated(self, modules):
|
|
text = "\n".join(f"k{n}:string" for n in range(INTENT_FIELD_LIMIT + 2))
|
|
|
|
fields, problems = modules.flow.intent_fields_from_text(text)
|
|
|
|
assert fields is None
|
|
assert any("cap" in problem for problem in problems)
|
|
|
|
|
|
class _FakeScan:
|
|
"""A stand-in for `manager.async_scan_intents` (t16's API)."""
|
|
|
|
def __init__(self, result=None, exc=None):
|
|
self.result = result
|
|
self.exc = exc
|
|
self.packages: list[str] = []
|
|
|
|
async def __call__(self, package):
|
|
self.packages.append(package)
|
|
if self.exc is not None:
|
|
raise self.exc
|
|
return self.result
|
|
|
|
|
|
def _candidate(**overrides):
|
|
"""One discovered candidate, with the dedup KEY the real one carries.
|
|
|
|
``candidate_value`` is built from ``IntentCandidate.key`` (t16), so the fake
|
|
has to have one — and two fakes differing only in a field must differ in it,
|
|
exactly as the real candidates do.
|
|
"""
|
|
base = {
|
|
"component": "com.netflix.ninja/.MainActivity",
|
|
"action": "android.intent.action.VIEW",
|
|
"categories": ("android.intent.category.DEFAULT",),
|
|
"scheme": "https",
|
|
"authority": "www.netflix.com",
|
|
"path": "/browse",
|
|
"path_kind": "LITERAL",
|
|
"likely_internal": False,
|
|
}
|
|
base.update(overrides)
|
|
base["key"] = (
|
|
base["component"],
|
|
base["action"],
|
|
base["categories"],
|
|
base["scheme"],
|
|
base["authority"],
|
|
base["path"],
|
|
base["path_kind"],
|
|
)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
def _scan_result(candidates=(), *, status="fresh", package="com.netflix.ninja"):
|
|
return SimpleNamespace(
|
|
package=package,
|
|
status=status,
|
|
candidates=tuple(candidates),
|
|
entry_state="candidates" if candidates else "empty",
|
|
age_seconds=0.0,
|
|
truncated=False,
|
|
)
|
|
|
|
|
|
class TestTheScanStep:
|
|
"""The scan answers honestly, and a candidate prefills only what it can."""
|
|
|
|
async def test_no_manager_anywhere_is_reported_not_crashed(self, modules, tmp_path):
|
|
flow = _flow(modules, tmp_path)
|
|
flow.config_entry = SimpleNamespace(options={}, entry_id="e1", title="Shield")
|
|
flow.hass.data = {} # no runtime_data, no domain store
|
|
|
|
result = await flow.async_step_intents_scan(
|
|
{INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"}
|
|
)
|
|
|
|
assert result["step_id"] == "intents_scan_result"
|
|
note = result["description_placeholders"]["scan_note"]
|
|
assert "no intent discovery" in note
|
|
assert "Nothing was registered" in note
|
|
|
|
async def test_a_bad_package_never_reaches_the_manager(self, modules, tmp_path):
|
|
flow = _flow(modules, tmp_path)
|
|
scan = _FakeScan(_scan_result())
|
|
flow.config_entry.runtime_data = SimpleNamespace(async_scan_intents=scan)
|
|
|
|
result = await flow.async_step_intents_scan({INTENT_FIELD_SCAN_PACKAGE: "not one"})
|
|
|
|
assert result["errors"] == {"base": "invalid_intent_package"}
|
|
assert scan.packages == []
|
|
|
|
async def test_one_package_is_scanned_and_its_candidates_offered(
|
|
self, modules, tmp_path
|
|
):
|
|
flow = _flow(modules, tmp_path)
|
|
scan = _FakeScan(
|
|
_scan_result(
|
|
[_candidate(), _candidate(path="/title.*", path_kind="GLOB")]
|
|
)
|
|
)
|
|
flow.config_entry.runtime_data = SimpleNamespace(async_scan_intents=scan)
|
|
|
|
result = await flow.async_step_intents_scan(
|
|
{INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"}
|
|
)
|
|
|
|
assert scan.packages == ["com.netflix.ninja"], "ONE package per request"
|
|
note = result["description_placeholders"]["scan_note"]
|
|
assert "2 candidate intent(s)" in note
|
|
assert "Nothing has been registered yet" in note
|
|
|
|
async def test_each_status_reads_differently(self, modules, tmp_path):
|
|
cases = {
|
|
"fresh_empty": "advertises no intents",
|
|
"adb_disabled": "switched OFF",
|
|
"adb_unavailable": "did not answer",
|
|
"unreadable": "did not answer",
|
|
"cached": "cache",
|
|
}
|
|
|
|
for status, expected in cases.items():
|
|
note = modules.flow.intent_scan_note(
|
|
_scan_result(status=status), "com.netflix.ninja"
|
|
)
|
|
assert expected in note, status
|
|
|
|
async def test_a_literal_candidate_prefills_a_real_url(self, modules, tmp_path):
|
|
candidate = _candidate() # https + authority + LITERAL /browse
|
|
flow = _flow(modules, tmp_path)
|
|
scan = _FakeScan(_scan_result([candidate]))
|
|
flow.config_entry.runtime_data = SimpleNamespace(async_scan_intents=scan)
|
|
await flow.async_step_intents_scan(
|
|
{INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"}
|
|
)
|
|
|
|
form = await flow.async_step_intents_scan_result(
|
|
{INTENT_FIELD_CANDIDATE: modules.flow.candidate_value(candidate)}
|
|
)
|
|
|
|
assert form["step_id"] == "intents_form"
|
|
assert form["type"] == "form" # the schema is rendered, not submitted
|
|
assert form["description_placeholders"]["intent_note"] == "new intent"
|
|
assert flow._intent_prefill[INTENT_FIELD_DATA] == "https://www.netflix.com/browse"
|
|
assert flow._intent_prefill[INTENT_FIELD_ACTIVITY] == candidate.component
|
|
assert flow._intent_prefill[INTENT_FIELD_ACTION] == candidate.action
|
|
|
|
async def test_a_path_pattern_prefills_no_url_and_says_why(self, modules, tmp_path):
|
|
candidate = _candidate(path="/title.*", path_kind="GLOB")
|
|
flow = _flow(modules, tmp_path)
|
|
scan = _FakeScan(_scan_result([candidate]))
|
|
flow.config_entry.runtime_data = SimpleNamespace(async_scan_intents=scan)
|
|
await flow.async_step_intents_scan(
|
|
{INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"}
|
|
)
|
|
|
|
form = await flow.async_step_intents_scan_result(
|
|
{INTENT_FIELD_CANDIDATE: modules.flow.candidate_value(candidate)}
|
|
)
|
|
|
|
assert flow._intent_prefill[INTENT_FIELD_DATA] == ""
|
|
assert "pattern" in form["description_placeholders"]["intent_note"]
|
|
|
|
async def test_an_internal_candidate_is_offered_and_marked(self, modules, tmp_path):
|
|
candidate = _candidate(action="com.netflix.action.DEBUG_FORCE_EXIT", likely_internal=True)
|
|
|
|
assert "[internal-looking]" in modules.flow.candidate_label(candidate)
|
|
|
|
flow = _flow(modules, tmp_path)
|
|
flow.config_entry.runtime_data = SimpleNamespace(
|
|
async_scan_intents=_FakeScan(_scan_result([candidate]))
|
|
)
|
|
await flow.async_step_intents_scan(
|
|
{INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"}
|
|
)
|
|
form = await flow.async_step_intents_scan_result(
|
|
{INTENT_FIELD_CANDIDATE: modules.flow.candidate_value(candidate)}
|
|
)
|
|
|
|
assert "internal hook" in form["description_placeholders"]["intent_note"]
|
|
|
|
async def test_a_scan_that_cannot_run_registers_nothing(self, modules, tmp_path):
|
|
flow = _flow(modules, tmp_path)
|
|
flow.config_entry.runtime_data = SimpleNamespace(
|
|
async_scan_intents=_FakeScan(exc=RuntimeError("adb died"))
|
|
)
|
|
|
|
result = await flow.async_step_intents_scan(
|
|
{INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"}
|
|
)
|
|
|
|
assert result["step_id"] == "intents_scan_result"
|
|
assert CONF_REGISTERED_INTENTS not in flow._options_dict
|
|
|
|
|
|
class TestTheSeed:
|
|
"""`registered_intents:` in configuration.yaml — validated, or named and dropped."""
|
|
|
|
def test_a_valid_seed_produces_the_form_s_row(self, modules):
|
|
seeded = modules.flow._seeded_intent_rows([NETFLIX_INTENT])
|
|
|
|
assert len(seeded) == 1
|
|
# …and it is the SAME row the form would have built from those values
|
|
typed, problems = modules.flow.intent_row_from_form(
|
|
{
|
|
INTENT_FIELD_NAME: NETFLIX_INTENT["name"],
|
|
INTENT_FIELD_PACKAGE: NETFLIX_INTENT["package"],
|
|
INTENT_FIELD_ACTIVITY: NETFLIX_INTENT["activity"],
|
|
INTENT_FIELD_ACTION: NETFLIX_INTENT["action"],
|
|
INTENT_FIELD_CATEGORIES: ", ".join(NETFLIX_INTENT["categories"]),
|
|
INTENT_FIELD_DATA: NETFLIX_INTENT["data"],
|
|
INTENT_FIELD_DECLARED: "id:string:required",
|
|
},
|
|
existing_id=NETFLIX_INTENT["id"],
|
|
taken_ids=set(),
|
|
taken_names=set(),
|
|
)
|
|
assert problems == []
|
|
assert seeded[0] == typed
|
|
|
|
def test_a_broken_row_is_dropped_with_a_warning(self, modules, caplog):
|
|
rows = [
|
|
NETFLIX_INTENT,
|
|
{"name": "No target", "package": "com.x"},
|
|
{"name": "Bad package", "package": "has spaces", "action": "a.B"},
|
|
"not a mapping",
|
|
]
|
|
|
|
with caplog.at_level("WARNING"):
|
|
seeded = modules.flow._seeded_intent_rows(rows)
|
|
|
|
assert [row["id"] for row in seeded] == [NETFLIX_INTENT["id"]]
|
|
assert len(caplog.records) == 3, [r.message for r in caplog.records]
|
|
assert all("registered_intents row" in r.message for r in caplog.records)
|
|
|
|
def test_an_explicit_id_is_kept_and_a_duplicate_is_skipped(self, modules, caplog):
|
|
with caplog.at_level("WARNING"):
|
|
seeded = modules.flow._seeded_intent_rows(
|
|
[NETFLIX_INTENT, {**NETFLIX_INTENT, "name": "Duplicate"}]
|
|
)
|
|
|
|
assert [row["id"] for row in seeded] == [NETFLIX_INTENT["id"]]
|
|
assert "repeats the id" in caplog.records[0].message
|
|
|
|
def test_every_documented_row_key_is_honoured(self, modules):
|
|
"""The README's documented keys and the seed validator must agree (t17).
|
|
|
|
A key the docs show but the validator drops would be worse than a typo:
|
|
it would look configured and do nothing.
|
|
"""
|
|
from test_readme import INTENT_ROW_KEYS
|
|
|
|
row = {
|
|
"id": "example",
|
|
"name": "Example",
|
|
"package": "com.example.app",
|
|
"activity": "com.example.app/.MainActivity",
|
|
"action": "android.intent.action.VIEW",
|
|
"categories": ["android.intent.category.DEFAULT"],
|
|
"data": "https://example.test/{slug}",
|
|
"fields": [{"key": "slug", "type": "string"}],
|
|
}
|
|
assert set(row) == INTENT_ROW_KEYS, "the fixture must cover the docs"
|
|
|
|
seeded = modules.flow._seeded_intent_rows([row])
|
|
|
|
assert set(seeded[0]) == INTENT_ROW_KEYS, "every documented key survives"
|
|
|
|
def test_a_non_list_block_is_ignored_with_a_warning(self, modules, caplog):
|
|
with caplog.at_level("WARNING"):
|
|
seeded = modules.flow._seeded_intent_rows("nope")
|
|
|
|
assert seeded == []
|
|
assert "must be a LIST" in caplog.records[0].message
|
|
|
|
|
|
# ── 4. the buttons ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestTheButtons:
|
|
"""One entity per registration, sending the defaults, failing loudly."""
|
|
|
|
async def _setup(self, modules, options):
|
|
entry = SimpleNamespace(
|
|
entry_id="e1", options=options, title="Shield", runtime_data=None
|
|
)
|
|
added: list = []
|
|
button = importlib.import_module("nsh.button")
|
|
await button.async_setup_entry(None, entry, added.extend)
|
|
return entry, added
|
|
|
|
async def test_one_button_per_registered_intent(self, modules):
|
|
_entry, added = await self._setup(
|
|
modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}
|
|
)
|
|
|
|
assert len(added) == 1
|
|
assert added[0]._attr_name == NETFLIX_INTENT["name"]
|
|
assert added[0]._attr_unique_id.endswith("_intent_netflix_title")
|
|
|
|
async def test_no_registration_means_no_button(self, modules):
|
|
_entry, added = await self._setup(modules, {})
|
|
|
|
assert added == []
|
|
|
|
async def test_a_rename_keeps_the_entity_identity(self, modules):
|
|
_entry, added = await self._setup(
|
|
modules, {CONF_REGISTERED_INTENTS: [{**NETFLIX_INTENT, "name": "Renamed"}]}
|
|
)
|
|
|
|
assert added[0]._attr_name == "Renamed"
|
|
assert added[0]._attr_unique_id.endswith("_intent_netflix_title")
|
|
|
|
async def test_the_button_shows_what_it_will_send(self, modules):
|
|
_entry, added = await self._setup(
|
|
modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}
|
|
)
|
|
|
|
attrs = added[0].extra_state_attributes
|
|
|
|
assert attrs["intent_id"] == "netflix_title"
|
|
assert attrs["activity"] == NETFLIX_INTENT["activity"]
|
|
assert attrs["fields"] == NETFLIX_INTENT["fields"]
|
|
|
|
async def test_pressing_sends_the_declared_defaults(self, modules):
|
|
entry, added = await self._setup(
|
|
modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}
|
|
)
|
|
poller = _RecordingPoller()
|
|
entry.runtime_data = _manager(poller)
|
|
button = added[0]
|
|
button.entity_id = "button.shield_netflix_open_a_title"
|
|
|
|
# with no default for the required field, the press is an honest error
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
await button.async_press()
|
|
assert caught.value.translation_key == "missing_intent_field"
|
|
assert poller.commands == [], "nothing was sent"
|
|
|
|
# give the field a default and the same press works
|
|
intent = {**NETFLIX_INTENT, "fields": [{"key": "id", "type": "string", "default": "7"}]}
|
|
_entry, added = await self._setup(modules, {CONF_REGISTERED_INTENTS: [intent]})
|
|
added[0]._manager = _manager(poller)
|
|
added[0].entity_id = "button.shield_netflix_open_a_title"
|
|
|
|
await added[0].async_press()
|
|
|
|
assert poller.commands == [
|
|
modules.media.intent_am_start_command(intent, {"id": "7"})
|
|
]
|
|
|
|
async def test_an_uninstalled_app_fails_the_press_honestly(self, modules):
|
|
"""The plan's acceptance: a package that is gone must not press silently.
|
|
|
|
The device is the authority here — its own sentence is what the error
|
|
carries, so the user learns WHICH component vanished instead of seeing a
|
|
button that did nothing.
|
|
"""
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
intent = {
|
|
**NETFLIX_INTENT,
|
|
"fields": [{"key": "id", "type": "string", "default": "7"}],
|
|
}
|
|
entry, added = await self._setup(
|
|
modules, {CONF_REGISTERED_INTENTS: [intent]}
|
|
)
|
|
poller = _RecordingPoller(
|
|
"Starting: Intent { act=android.intent.action.VIEW }\n"
|
|
"Error: Activity class {com.netflix.ninja/.MainActivity} does not exist."
|
|
)
|
|
entry.runtime_data = _manager(poller)
|
|
added[0]._manager = _manager(poller)
|
|
added[0].entity_id = "button.shield_netflix_open_a_title"
|
|
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
await added[0].async_press()
|
|
|
|
assert caught.value.translation_key == "intent_failed"
|
|
assert "does not exist" in str(caught.value)
|
|
assert "com.netflix.ninja/.MainActivity" in str(caught.value)
|
|
assert len(poller.commands) == 1, "attempted once, never retried"
|
|
|
|
async def test_a_button_with_no_manager_fails_loudly(self, modules):
|
|
"""A press that cannot reach the Shield RAISES — it never no-ops.
|
|
|
|
The field gets a default here on purpose: that leaves ADB as the only
|
|
thing wrong, so the assertion can pin WHICH error the user sees.
|
|
"""
|
|
_entry, added = await self._setup(
|
|
modules,
|
|
{
|
|
CONF_REGISTERED_INTENTS: [
|
|
{
|
|
**NETFLIX_INTENT,
|
|
"fields": [{"key": "id", "type": "string", "default": "7"}],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
await added[0].async_press()
|
|
|
|
assert caught.value.translation_key == "intents_need_adb"
|
|
|
|
|
|
# ── 5. the action ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class _Call:
|
|
"""The shape `ServiceCall` has for the handler (data + hass)."""
|
|
|
|
def __init__(self, data, hass=None):
|
|
self.data = data
|
|
self.hass = hass
|
|
|
|
|
|
class TestTheAction:
|
|
"""`nvidia_shield.send_intent`: the lookup, the variables, the response."""
|
|
|
|
def _wire(self, modules, options, poller=None, entity_id="media_player.shield"):
|
|
manager = _manager(poller or _RecordingPoller())
|
|
manager.entry = SimpleNamespace(options=options)
|
|
modules.media._INTENT_TARGETS[entity_id] = manager
|
|
return manager
|
|
|
|
def _cleanup(self, modules, entity_id="media_player.shield"):
|
|
modules.media._INTENT_TARGETS.pop(entity_id, None)
|
|
|
|
async def test_the_output_comes_back_under_the_response_key(self, modules):
|
|
poller = _RecordingPoller()
|
|
self._wire(modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}, poller)
|
|
try:
|
|
result = await modules.media._async_send_intent_service(
|
|
_Call(
|
|
{
|
|
"intent": NETFLIX_INTENT["name"],
|
|
"variables": {"id": "1"},
|
|
"entity_id": "media_player.shield",
|
|
}
|
|
)
|
|
)
|
|
finally:
|
|
self._cleanup(modules)
|
|
|
|
assert set(result) == {INTENT_RESPONSE_KEY}
|
|
assert result[INTENT_RESPONSE_KEY].startswith("Starting: Intent")
|
|
assert poller.commands == [
|
|
modules.media.intent_am_start_command(NETFLIX_INTENT, {"id": "1"})
|
|
]
|
|
|
|
async def test_an_unknown_intent_lists_the_known_names(self, modules):
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
self._wire(modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
|
try:
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
await modules.media._async_send_intent_service(
|
|
_Call({"intent": "nope", "entity_id": "media_player.shield"})
|
|
)
|
|
finally:
|
|
self._cleanup(modules)
|
|
|
|
assert caught.value.translation_key == "unknown_intent"
|
|
assert caught.value.translation_placeholders["known"] == NETFLIX_INTENT["name"]
|
|
|
|
async def test_no_registered_intents_yet_is_said_plainly(self, modules):
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
self._wire(modules, {})
|
|
try:
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
await modules.media._async_send_intent_service(
|
|
_Call({"intent": "anything", "entity_id": "media_player.shield"})
|
|
)
|
|
finally:
|
|
self._cleanup(modules)
|
|
|
|
assert caught.value.translation_placeholders["known"] == "none yet"
|
|
|
|
async def test_variables_must_be_a_mapping(self, modules):
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
self._wire(modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
|
try:
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
await modules.media._async_send_intent_service(
|
|
_Call(
|
|
{
|
|
"intent": NETFLIX_INTENT["name"],
|
|
"variables": "id=1",
|
|
"entity_id": "media_player.shield",
|
|
}
|
|
)
|
|
)
|
|
finally:
|
|
self._cleanup(modules)
|
|
|
|
assert caught.value.translation_key == "invalid_intent_variables"
|
|
|
|
async def test_the_target_errors_are_the_shared_ones(self, modules):
|
|
import homeassistant.exceptions as ha_exceptions
|
|
|
|
self._wire(modules, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
|
try:
|
|
# nothing selected
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
await modules.media._async_send_intent_service(
|
|
_Call({"intent": NETFLIX_INTENT["name"]})
|
|
)
|
|
assert caught.value.translation_key == "no_target"
|
|
assert "send_intent" in caught.value.translation_placeholders["service"]
|
|
|
|
# an entity that does not participate in THIS action
|
|
with pytest.raises(ha_exceptions.ServiceValidationError) as caught:
|
|
await modules.media._async_send_intent_service(
|
|
_Call({"intent": "x", "entity_id": "media_player.other"})
|
|
)
|
|
assert caught.value.translation_key == "unsupported_target"
|
|
finally:
|
|
self._cleanup(modules)
|
|
|
|
async def test_the_key_a_template_reads_is_not_adb_response(self, modules):
|
|
from nsh.const import INTENT_RESPONSE_KEY
|
|
|
|
assert INTENT_RESPONSE_KEY == "intent_response"
|
|
|
|
|
|
class TestTheServiceDescription:
|
|
"""The dropdown the frontend renders comes from the runtime description."""
|
|
|
|
def test_the_intent_field_is_a_dropdown_of_registered_names(self, modules):
|
|
description = modules.media.send_intent_service_description(
|
|
["Netflix: open a title", "TiviMate: channel"]
|
|
)
|
|
|
|
intent_field = description["fields"]["intent"]
|
|
assert intent_field["required"] is True
|
|
selector = intent_field["selector"]["select"]
|
|
assert sorted(selector["options"]) == [
|
|
"Netflix: open a title",
|
|
"TiviMate: channel",
|
|
]
|
|
assert selector["custom_value"] is True
|
|
assert selector["mode"] == "dropdown"
|
|
|
|
def test_the_description_declares_the_target_and_the_variables_field(self, modules):
|
|
description = modules.media.send_intent_service_description([])
|
|
|
|
assert description["target"] == {"entity": [{"domain": "media_player"}]}
|
|
assert "variables" in description["fields"]
|
|
assert description["fields"]["variables"]["selector"] == {"object": {}}
|
|
assert description["fields"]["intent"]["selector"]["select"]["options"] == []
|
|
|
|
def test_the_strings_files_carry_the_same_service_and_exception_keys(self):
|
|
"""The runtime description and the translation cannot disagree."""
|
|
import json
|
|
|
|
base = Path(__file__).resolve().parent.parent / "custom_components"
|
|
shield = base / "nvidia_shield"
|
|
for name in ("strings.json", "translations/en.json"):
|
|
data = json.loads((shield / name).read_text(encoding="utf-8"))
|
|
assert set(data["services"]["send_intent"]["fields"]) == {
|
|
"intent",
|
|
"variables",
|
|
"entity_id",
|
|
}
|
|
for key in (
|
|
"unknown_intent",
|
|
"missing_intent_field",
|
|
"unknown_intent_variable",
|
|
"invalid_intent_field",
|
|
"invalid_intent_variables",
|
|
"unresolved_intent_placeholder",
|
|
"intents_need_adb",
|
|
"intent_no_answer",
|
|
"intent_failed",
|
|
):
|
|
assert key in data["exceptions"], (name, key)
|
|
|
|
|
|
class TestNothingRunsByItself:
|
|
"""The plan's non-negotiable: registration is inert until someone calls it."""
|
|
|
|
async def test_loading_the_options_screen_touches_no_device(self, modules, tmp_path):
|
|
poller = _RecordingPoller()
|
|
flow = _flow(modules, tmp_path, {CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
|
flow.config_entry.runtime_data = SimpleNamespace(
|
|
async_scan_intents=_FakeScan(_scan_result()),
|
|
adb_poller=poller,
|
|
)
|
|
|
|
for step in ("intents", "intents_edit", "intents_remove", "intents_scan"):
|
|
result = await getattr(flow, f"async_step_{step}")(None)
|
|
assert result["type"] in ("form", "menu")
|
|
|
|
assert poller.commands == [], "rendering a screen never runs anything"
|
|
|
|
async def test_a_scan_never_registers_anything(self, modules, tmp_path):
|
|
flow = _flow(modules, tmp_path)
|
|
flow.config_entry.runtime_data = SimpleNamespace(
|
|
async_scan_intents=_FakeScan(_scan_result([_candidate()]))
|
|
)
|
|
|
|
await flow.async_step_intents_scan(
|
|
{INTENT_FIELD_SCAN_PACKAGE: "com.netflix.ninja"}
|
|
)
|
|
|
|
assert flow._options_dict.get(CONF_REGISTERED_INTENTS) in (None, [])
|
|
|
|
async def test_a_registered_intent_is_inert_until_pressed(self, modules):
|
|
_entry, added = await self._setup_intents(modules)
|
|
|
|
assert added, "the button exists"
|
|
assert added[0]._manager is None or added[0]._manager.adb_poller is not None
|
|
|
|
async def _setup_intents(self, modules):
|
|
entry = SimpleNamespace(
|
|
entry_id="e1",
|
|
options={CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]},
|
|
title="Shield",
|
|
runtime_data=None,
|
|
)
|
|
added: list = []
|
|
button = importlib.import_module("nsh.button")
|
|
await button.async_setup_entry(None, entry, added.extend)
|
|
return entry, added
|
|
|
|
|
|
def test_the_text_helpers_do_not_run_in_a_thread():
|
|
"""A guard against a future rewrite reaching for `eval`/`exec` (t17)."""
|
|
source = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "custom_components"
|
|
/ "nvidia_shield"
|
|
/ "media_player.py"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "eval(" not in source
|
|
assert "exec(" not in source
|
|
assert ".format(" not in source.split("def intent_am_start_command")[1].split("\ndef ")[0]
|
|
|
|
|
|
def test_asyncio_is_still_needed_by_the_module_under_test():
|
|
assert asyncio is not None # keeps the import honest if the tests shrink
|
|
|
|
|
|
def test_a_truncated_capture_prefills_no_url(modules):
|
|
"""t18/F2 at the CONSUMPTION end: a cut detail must not become a form URL.
|
|
|
|
The parser drops an unterminated quoted value (see
|
|
tests/test_intents.py::TestATruncatedValueIsNotFabricated); this is the
|
|
consequence the review named — `_candidate_data` must not build
|
|
``https://www.exam`` out of a host it only half-read.
|
|
"""
|
|
from nsh.ha_adb_common import parse_activity_resolver_table
|
|
|
|
capture = (
|
|
"Activity Resolver Table:\n"
|
|
" Schemes:\n"
|
|
" https:\n"
|
|
" deca7fa com.netflix.ninja/.MainActivity filter fc1\n"
|
|
' Action: "android.intent.action.VIEW"\n'
|
|
' Scheme: "ht\n'
|
|
' Authority: "www.exam\n'
|
|
"Service Resolver Table:\n"
|
|
)
|
|
[candidate] = parse_activity_resolver_table(capture)
|
|
|
|
values, _note = modules.flow.intent_prefill_from_candidate("com.netflix.ninja", candidate)
|
|
|
|
assert values[INTENT_FIELD_DATA] == "", "a cut scheme/authority prefills NO data"
|
|
assert "www.exam" not in values[INTENT_FIELD_DATA]
|
|
assert values[INTENT_FIELD_ACTION] == "android.intent.action.VIEW"
|
|
assert values[INTENT_FIELD_ACTIVITY] == "com.netflix.ninja/.MainActivity"
|