fix(config-flow): the setup menu was unreachable under real HA (t57 UNIT 0a)
t61 F1 (BLOCKER) + F2 + F3 + F5, all reproduced against a real FlowManager
before fixing. The OPTIONS flow was verified good and is untouched by this
commit; the SETUP flow was broken in three independent ways.
F1 — `_show_setup_menu` passed `errors=` to `async_show_menu`, which has no
such parameter (HA 2026.10.1: step_id / menu_options / sort /
description_placeholders). Every path that went near the menu therefore
raised `TypeError` the moment the first form was submitted or ADB pairing
was skipped. The path map, driven end to end: FOUR of the five ordinary
paths died before create_entry ("ADB skip", "ADB paired + protocol off",
"ADB skip + protocol skip", "pair ADB"); only "protocol only" survived, and
it never showed the menu — so a new entry could only be created on that one
path and the sub-screens were unreachable.
F2 — the menu's rows broke the rule this repo documents: HA dispatches a row
to `async_step_<row id>` AND dispatches a submitted form by its OWN step_id,
so a row named `adb` with a handler called `async_step_adb_pair` is an
UnknownStep, and a row named `receiver` rendering `step_id="setup_receiver"`
is a form that cannot be submitted. The rows are now their handler names
(`receiver`, `connection`, `adb_pair`, `protocol_pair`, `finish`) and the
two sub-screens render their row id. There is also a `_paired_adb` guard
now, so a completed ADB pairing stops offering that row — the rule the
protocol row already followed.
F3 — both menus keyed their row labels under a bare `menu`, which core does
not read (56 core strings files use `menu_options`), so every row fell back
to its raw id in the browser. Renamed in both strings files.
F5 — the per-command script merge is field-wise now: a `script_<command>`
key that is present and empty removes that command, one with a value sets
it, and a key the submission does not carry is untouched — the same rule as
every other option. The wholesale rebuild depended on the page always being
complete, so a partial submission silently wiped the commands it omitted.
THE SYSTEMIC HALF (F1), closed as asked: `ha_api_drift.HA_CALL_OWNERS` now
covers the flow-display APIs (`async_show_menu`, `async_show_progress`,
`async_show_progress_done`, plus the existing form/abort/entry owners), so
the drift engine resolves their REAL signatures and flags an unknown
keyword. `tests/test_ha_api_drift.py` carries a pre-fix source using
`errors=` to prove the new owner is non-vacuous. Every harness stub now
mirrors the real signature (no `errors` on a menu) and a stub-level test
pins that parameter set, so a stub cannot go back to accepting anything.
On the pre-fix tree the drift engine reports exactly this:
config_flow.py:898: unknown-kwarg: async_show_menu() has no 'errors'
parameter; real signature: (self, *, step_id=None, menu_options, sort,
description_placeholders) -> _FlowResultT
TESTS: tests/test_config_flow_real_ha.py gains a SETUP section that drives
the real flow — the first form, the ADB skip, the pairing guard, EVERY menu
row (dispatch AND re-submittable step_id), an accepted sub-screen
submission, a full connection+receiver+finish walk merging into one entry,
the menu's pairing note, and the five-path map as a parametrised test.
11 of those fail on the pre-fix tree; the drift engine failure above is the
12th. The stub suite additionally pins the menu stub's signature and the
`menu_options` key in both strings files.
Also in this commit (t57 F4): the real-HA file claimed entity-valued pages
could not be driven there, which was false (it has always submitted
`switch.soundbar`). The claim is gone and the scripts page is driven through
SET (two scripts selected, real entity ids) → CLEAR (one selector emptied)
→ SAVE → REOPEN, so the SET half is pinned in the real flow and not only
against the selector objects.
This commit is contained in:
1 parent
5eefd0c6d0
commit
3a55519b74
10 files changed
+713
-208
No files matched your search
@@ -875,29 +875,66 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
self._setup_extra_options = {}
|
self._setup_extra_options = {}
|
||||||
return self._setup_extra_options
|
return self._setup_extra_options
|
||||||
|
|
||||||
def _show_setup_menu(self, errors=None):
|
def _show_setup_menu(self):
|
||||||
"""The setup menu: connection, receiver, and finishing the entry (t56).
|
"""The setup menu: receiver, connection, pairing, finish (t56).
|
||||||
|
|
||||||
Home Assistant's standard menu step: it renders every option as a row
|
Home Assistant's standard menu step: it renders every option as a row and
|
||||||
and dispatches the choice to ``async_step_<option>``. Both transports
|
dispatches the choice to ``async_step_<row id>``.
|
||||||
stay optional (the pairing steps keep their skip), so the user can
|
|
||||||
finish at any point. A transport that already paired is not offered
|
TWO rules this menu has to obey, both learned the hard way:
|
||||||
again — that row would re-open a pairing session the Shield no longer
|
|
||||||
needs.
|
* the row id must BE its handler name — ``receiver``,
|
||||||
|
``connection``, ``adb_pair``, ``protocol_pair``, ``finish`` — because
|
||||||
|
that is literally how HA dispatches (``_raise_if_step_does_not_exist``
|
||||||
|
on ``async_step_<row id>``). A friendlier alias like ``adb`` is an
|
||||||
|
UnknownStep.
|
||||||
|
* a sub-screen's rendered ``step_id`` must be the SAME string, because a
|
||||||
|
submitted form is dispatched by its own step_id. Rendering
|
||||||
|
``setup_receiver`` from the ``receiver`` row makes the form
|
||||||
|
unsubmittable.
|
||||||
|
|
||||||
|
``async_show_menu`` takes no ``errors`` (verified against HA 2026.10.1:
|
||||||
|
``step_id``/``menu_options``/``sort``/``description_placeholders``
|
||||||
|
only), which is why this method takes none either.
|
||||||
|
|
||||||
|
Both transports stay optional (the pairing steps keep their skip), so
|
||||||
|
the user can finish at any point. A transport that already paired is not
|
||||||
|
offered again — that row would re-open a pairing session the Shield no
|
||||||
|
longer needs (file an entry option to change it).
|
||||||
"""
|
"""
|
||||||
options = ["finish"]
|
# built in one explicit order rather than a chain of insert(0), which
|
||||||
if self._user_data.get(CONF_ADB_ENABLED):
|
# is how this read backwards until real HA showed the row order
|
||||||
options.insert(0, "adb")
|
options = ["receiver", "connection"]
|
||||||
|
if self._user_data.get(CONF_ADB_ENABLED) and not getattr(
|
||||||
|
self, "_paired_adb", False
|
||||||
|
):
|
||||||
|
options.append("adb_pair")
|
||||||
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
|
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
|
||||||
self, "_paired_protocol", False
|
self, "_paired_protocol", False
|
||||||
):
|
):
|
||||||
options.insert(0, "protocol")
|
options.append("protocol_pair")
|
||||||
options.insert(0, "connection")
|
options.append("finish")
|
||||||
options.insert(0, "receiver")
|
|
||||||
return self.async_show_menu(
|
return self.async_show_menu(
|
||||||
step_id="menu", menu_options=options, errors=errors or {}
|
step_id="menu",
|
||||||
|
menu_options=options,
|
||||||
|
description_placeholders=self._setup_menu_placeholders(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _setup_menu_placeholders(self) -> dict:
|
||||||
|
"""What the setup menu says about the transports it still offers."""
|
||||||
|
pending = []
|
||||||
|
if self._user_data.get(CONF_ADB_ENABLED) and not getattr(
|
||||||
|
self, "_paired_adb", False
|
||||||
|
):
|
||||||
|
pending.append("ADB")
|
||||||
|
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
|
||||||
|
self, "_paired_protocol", False
|
||||||
|
):
|
||||||
|
pending.append("the remote protocol")
|
||||||
|
if not pending:
|
||||||
|
return {"pairing": "everything that needs pairing is paired"}
|
||||||
|
return {"pairing": "still to pair: " + " and ".join(pending)}
|
||||||
|
|
||||||
async def async_step_menu(self, user_input=None):
|
async def async_step_menu(self, user_input=None):
|
||||||
"""First visit: open the pending pairing; later visits: show the menu.
|
"""First visit: open the pending pairing; later visits: show the menu.
|
||||||
|
|
||||||
@@ -962,9 +999,9 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
fields[vol.Optional(CONF_RECEIVER_INPUT, default=current_input)] = str
|
fields[vol.Optional(CONF_RECEIVER_INPUT, default=current_input)] = str
|
||||||
else:
|
else:
|
||||||
fields[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
fields[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
||||||
return self.async_show_form(
|
# the step_id IS the menu row id: HA dispatches a submitted form by its
|
||||||
step_id="setup_receiver", data_schema=vol.Schema(fields)
|
# own step_id, so anything else is an UnknownStep on submit
|
||||||
)
|
return self.async_show_form(step_id="receiver", data_schema=vol.Schema(fields))
|
||||||
|
|
||||||
async def async_step_connection(self, user_input=None):
|
async def async_step_connection(self, user_input=None):
|
||||||
"""Setup sub-screen: the address, the ADB port and the two transports.
|
"""Setup sub-screen: the address, the ADB port and the two transports.
|
||||||
@@ -1003,9 +1040,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
default=bool(self._user_data.get(CONF_PROTOCOL_ENABLED, True)),
|
default=bool(self._user_data.get(CONF_PROTOCOL_ENABLED, True)),
|
||||||
): bool,
|
): bool,
|
||||||
}
|
}
|
||||||
return self.async_show_form(
|
return self.async_show_form(step_id="connection", data_schema=vol.Schema(fields))
|
||||||
step_id="setup_connection", data_schema=vol.Schema(fields)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_finish(self, user_input=None):
|
async def async_step_finish(self, user_input=None):
|
||||||
"""The menu's finish row: create the entry from the first form + sub-screens."""
|
"""The menu's finish row: create the entry from the first form + sub-screens."""
|
||||||
@@ -1082,6 +1117,9 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
host, port, key_path
|
host, port, key_path
|
||||||
)
|
)
|
||||||
if connected:
|
if connected:
|
||||||
|
# t57 F2: paired, so the menu must stop offering that row —
|
||||||
|
# the same rule the protocol row already follows
|
||||||
|
self._paired_adb = True
|
||||||
return await self._async_after_pairing()
|
return await self._async_after_pairing()
|
||||||
else:
|
else:
|
||||||
errors["base"] = "adb_failed"
|
errors["base"] = "adb_failed"
|
||||||
@@ -1266,26 +1304,31 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
|||||||
return False
|
return False
|
||||||
present[CONF_CHANNEL_DIGIT_DELAY] = delay
|
present[CONF_CHANNEL_DIGIT_DELAY] = delay
|
||||||
|
|
||||||
# ── the per-command scripts: the submitted fields ARE the dict ──
|
# ── the per-command scripts: per FIELD, which is the unambiguous rule ──
|
||||||
#
|
#
|
||||||
# Rebuilding from the submitted fields is what makes removal persist: a
|
# A cleared selector arrives as an empty value for THAT field (t57
|
||||||
# script configured for `turn_on` and then cleared arrives as '' for
|
# UNIT 0a verified the shape against real HA), so clearing is a field
|
||||||
# that field, and a dict built only from the non-empty fields drops it
|
# operation: a `script_<command>` key that is present and empty REMOVES
|
||||||
# instead of leaving a stale key behind. A command the submission does
|
# its command, one that is present with a value sets it, and a key the
|
||||||
# not carry is therefore cleared too — the Scripts screen renders every
|
# submission does not carry is untouched — the same rule as every other
|
||||||
# field, so the only way a key goes missing is a hand-made partial
|
# option. Nothing depends on the page being complete, so a hand-made
|
||||||
# submission, and this keeps the old "a save without scripts clears
|
# partial submission cannot silently wipe the commands it omits. The
|
||||||
# them" contract unambiguous rather than shape-dependent.
|
# starting point is the normalized `command_scripts` view, so a command
|
||||||
if any(field.startswith(SCRIPT_FIELD_PREFIX) for field in present):
|
# this version no longer knows about is dropped rather than carried.
|
||||||
present[CONF_COMMAND_SCRIPTS] = {
|
script_fields = [f for f in present if f.startswith(SCRIPT_FIELD_PREFIX)]
|
||||||
command: str(present.get(f"{SCRIPT_FIELD_PREFIX}{command}") or "").strip()
|
if script_fields:
|
||||||
for command in SCRIPT_COMMANDS
|
scripts = dict(command_scripts(self._options_dict))
|
||||||
if str(present.get(f"{SCRIPT_FIELD_PREFIX}{command}") or "").strip()
|
for field in script_fields:
|
||||||
}
|
command = field[len(SCRIPT_FIELD_PREFIX):]
|
||||||
for field in [f for f in present if f.startswith(SCRIPT_FIELD_PREFIX)]:
|
value = str(present[field] or "").strip()
|
||||||
# UI only: the service layer reads the dict, never a flat key.
|
if command in SCRIPT_COMMANDS and value:
|
||||||
# Dropped AFTER the rebuild — the rebuild reads them.
|
scripts[command] = value
|
||||||
|
else:
|
||||||
|
# cleared, or a field this version has no command for
|
||||||
|
scripts.pop(command, None)
|
||||||
|
# UI only: the service layer reads the dict, never a flat key
|
||||||
present.pop(field)
|
present.pop(field)
|
||||||
|
present[CONF_COMMAND_SCRIPTS] = scripts
|
||||||
|
|
||||||
for key, value in present.items():
|
for key, value in present.items():
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|||||||
@@ -28,6 +28,57 @@
|
|||||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"menu": {
|
||||||
|
"title": "Finish setup",
|
||||||
|
"description": "Pair the transports and check anything else before the entry is created. {pairing}.",
|
||||||
|
"data": {
|
||||||
|
"host": "The Shield's IP address",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"adb_enabled": "Read state over ADB",
|
||||||
|
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||||
|
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||||
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||||
|
},
|
||||||
|
"menu_options": {
|
||||||
|
"receiver": "Receiver",
|
||||||
|
"connection": "Connection",
|
||||||
|
"adb_pair": "Set up ADB",
|
||||||
|
"protocol_pair": "Pair the remote protocol",
|
||||||
|
"finish": "Create the entry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"receiver": {
|
||||||
|
"title": "Receiver",
|
||||||
|
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch.",
|
||||||
|
"data": {
|
||||||
|
"receiver_entity": "Receiver",
|
||||||
|
"receiver_input": "Receiver input"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||||
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"title": "Connection",
|
||||||
|
"description": "The Shield's address, the ADB port and which transports this entry uses.",
|
||||||
|
"data": {
|
||||||
|
"host": "The Shield's IP address",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"adb_enabled": "Read state over ADB",
|
||||||
|
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||||
|
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||||
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||||
|
}
|
||||||
|
},
|
||||||
"adb_pair": {
|
"adb_pair": {
|
||||||
"title": "Allow ADB access",
|
"title": "Allow ADB access",
|
||||||
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit — the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
|
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit — the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
|
||||||
@@ -59,57 +110,6 @@
|
|||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
"title": "Re-pair Nvidia Shield",
|
"title": "Re-pair Nvidia Shield",
|
||||||
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again — the Shield then shows a code."
|
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again — the Shield then shows a code."
|
||||||
},
|
|
||||||
"menu": {
|
|
||||||
"title": "Finish setup",
|
|
||||||
"description": "Pair the transports and check anything else before the entry is created.",
|
|
||||||
"menu": {
|
|
||||||
"receiver": "Receiver",
|
|
||||||
"connection": "Connection",
|
|
||||||
"adb": "Set up ADB",
|
|
||||||
"protocol": "Pair the remote protocol",
|
|
||||||
"finish": "Create the entry"
|
|
||||||
},
|
|
||||||
"data": {
|
|
||||||
"host": "The Shield's IP address",
|
|
||||||
"adb_port": "ADB port",
|
|
||||||
"adb_enabled": "Read state over ADB",
|
|
||||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
|
||||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
|
||||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
|
||||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"setup_connection": {
|
|
||||||
"title": "Connection",
|
|
||||||
"description": "The Shield's address, the ADB port and which transports this entry uses.",
|
|
||||||
"data": {
|
|
||||||
"host": "The Shield's IP address",
|
|
||||||
"adb_port": "ADB port",
|
|
||||||
"adb_enabled": "Read state over ADB",
|
|
||||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
|
||||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
|
||||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
|
||||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"setup_receiver": {
|
|
||||||
"title": "Receiver",
|
|
||||||
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch.",
|
|
||||||
"data": {
|
|
||||||
"receiver_entity": "Receiver",
|
|
||||||
"receiver_input": "Receiver input"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
|
||||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -143,14 +143,6 @@
|
|||||||
"init": {
|
"init": {
|
||||||
"title": "NVIDIA Shield settings",
|
"title": "NVIDIA Shield settings",
|
||||||
"description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
|
"description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
|
||||||
"menu": {
|
|
||||||
"connection": "Main setup",
|
|
||||||
"receiver": "Receiver",
|
|
||||||
"apps": "Apps",
|
|
||||||
"discover": "Discover installed apps",
|
|
||||||
"scripts": "Scripts",
|
|
||||||
"finish": "Save and close"
|
|
||||||
},
|
|
||||||
"data": {
|
"data": {
|
||||||
"jellyfin_entity": "Jellyfin media player",
|
"jellyfin_entity": "Jellyfin media player",
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
@@ -191,6 +183,14 @@
|
|||||||
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
|
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
|
||||||
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default — polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
|
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default — polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
|
||||||
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
|
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
|
||||||
|
},
|
||||||
|
"menu_options": {
|
||||||
|
"connection": "Main setup",
|
||||||
|
"receiver": "Receiver",
|
||||||
|
"apps": "Apps",
|
||||||
|
"discover": "Discover installed apps",
|
||||||
|
"scripts": "Scripts",
|
||||||
|
"finish": "Save and close"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
|
|||||||
@@ -28,6 +28,57 @@
|
|||||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"menu": {
|
||||||
|
"title": "Finish setup",
|
||||||
|
"description": "Pair the transports and check anything else before the entry is created. {pairing}.",
|
||||||
|
"data": {
|
||||||
|
"host": "The Shield's IP address",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"adb_enabled": "Read state over ADB",
|
||||||
|
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||||
|
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||||
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||||
|
},
|
||||||
|
"menu_options": {
|
||||||
|
"receiver": "Receiver",
|
||||||
|
"connection": "Connection",
|
||||||
|
"adb_pair": "Set up ADB",
|
||||||
|
"protocol_pair": "Pair the remote protocol",
|
||||||
|
"finish": "Create the entry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"receiver": {
|
||||||
|
"title": "Receiver",
|
||||||
|
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch.",
|
||||||
|
"data": {
|
||||||
|
"receiver_entity": "Receiver",
|
||||||
|
"receiver_input": "Receiver input"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||||
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"title": "Connection",
|
||||||
|
"description": "The Shield's address, the ADB port and which transports this entry uses.",
|
||||||
|
"data": {
|
||||||
|
"host": "The Shield's IP address",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"adb_enabled": "Read state over ADB",
|
||||||
|
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||||
|
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||||
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||||
|
}
|
||||||
|
},
|
||||||
"adb_pair": {
|
"adb_pair": {
|
||||||
"title": "Allow ADB access",
|
"title": "Allow ADB access",
|
||||||
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit — the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
|
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit — the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
|
||||||
@@ -59,57 +110,6 @@
|
|||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
"title": "Re-pair Nvidia Shield",
|
"title": "Re-pair Nvidia Shield",
|
||||||
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again — the Shield then shows a code."
|
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again — the Shield then shows a code."
|
||||||
},
|
|
||||||
"menu": {
|
|
||||||
"title": "Finish setup",
|
|
||||||
"description": "Pair the transports and check anything else before the entry is created.",
|
|
||||||
"menu": {
|
|
||||||
"receiver": "Receiver",
|
|
||||||
"connection": "Connection",
|
|
||||||
"adb": "Set up ADB",
|
|
||||||
"protocol": "Pair the remote protocol",
|
|
||||||
"finish": "Create the entry"
|
|
||||||
},
|
|
||||||
"data": {
|
|
||||||
"host": "The Shield's IP address",
|
|
||||||
"adb_port": "ADB port",
|
|
||||||
"adb_enabled": "Read state over ADB",
|
|
||||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
|
||||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
|
||||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
|
||||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"setup_connection": {
|
|
||||||
"title": "Connection",
|
|
||||||
"description": "The Shield's address, the ADB port and which transports this entry uses.",
|
|
||||||
"data": {
|
|
||||||
"host": "The Shield's IP address",
|
|
||||||
"adb_port": "ADB port",
|
|
||||||
"adb_enabled": "Read state over ADB",
|
|
||||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
|
||||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
|
||||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
|
||||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"setup_receiver": {
|
|
||||||
"title": "Receiver",
|
|
||||||
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch.",
|
|
||||||
"data": {
|
|
||||||
"receiver_entity": "Receiver",
|
|
||||||
"receiver_input": "Receiver input"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
|
||||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -143,14 +143,6 @@
|
|||||||
"init": {
|
"init": {
|
||||||
"title": "NVIDIA Shield settings",
|
"title": "NVIDIA Shield settings",
|
||||||
"description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
|
"description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
|
||||||
"menu": {
|
|
||||||
"connection": "Main setup",
|
|
||||||
"receiver": "Receiver",
|
|
||||||
"apps": "Apps",
|
|
||||||
"discover": "Discover installed apps",
|
|
||||||
"scripts": "Scripts",
|
|
||||||
"finish": "Save and close"
|
|
||||||
},
|
|
||||||
"data": {
|
"data": {
|
||||||
"jellyfin_entity": "Jellyfin media player",
|
"jellyfin_entity": "Jellyfin media player",
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
@@ -191,6 +183,14 @@
|
|||||||
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
|
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
|
||||||
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default — polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
|
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default — polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
|
||||||
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
|
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
|
||||||
|
},
|
||||||
|
"menu_options": {
|
||||||
|
"connection": "Main setup",
|
||||||
|
"receiver": "Receiver",
|
||||||
|
"apps": "Apps",
|
||||||
|
"discover": "Discover installed apps",
|
||||||
|
"scripts": "Scripts",
|
||||||
|
"finish": "Save and close"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
|
|||||||
@@ -96,8 +96,16 @@ SHIELD_DIR = (
|
|||||||
HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
|
HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
|
||||||
"async_update_reload_and_abort": ("homeassistant.config_entries", "ConfigFlow"),
|
"async_update_reload_and_abort": ("homeassistant.config_entries", "ConfigFlow"),
|
||||||
"async_show_form": ("homeassistant.config_entries", "ConfigFlow"),
|
"async_show_form": ("homeassistant.config_entries", "ConfigFlow"),
|
||||||
"async_create_entry": ("homeassistant.config_entries", "ConfigFlow"),
|
# t57 UNIT 0a: the FLOW-DISPLAY API surface. These are the calls that decide
|
||||||
|
# what the user sees, and a wrong keyword here is not a style error — it is a
|
||||||
|
# TypeError the moment a user submits a form, on every path through it.
|
||||||
|
# `async_show_menu` takes NO `errors` (HA 2026.10.1: step_id, menu_options,
|
||||||
|
# sort, description_placeholders), which is precisely the bug this guards.
|
||||||
|
"async_show_menu": ("homeassistant.data_entry_flow", "FlowHandler"),
|
||||||
|
"async_show_progress": ("homeassistant.data_entry_flow", "FlowHandler"),
|
||||||
|
"async_show_progress_done": ("homeassistant.data_entry_flow", "FlowHandler"),
|
||||||
"async_abort": ("homeassistant.config_entries", "ConfigFlow"),
|
"async_abort": ("homeassistant.config_entries", "ConfigFlow"),
|
||||||
|
"async_create_entry": ("homeassistant.config_entries", "ConfigFlow"),
|
||||||
"async_set_unique_id": ("homeassistant.config_entries", "ConfigFlow"),
|
"async_set_unique_id": ("homeassistant.config_entries", "ConfigFlow"),
|
||||||
"_abort_if_unique_id_configured": (
|
"_abort_if_unique_id_configured": (
|
||||||
"homeassistant.config_entries",
|
"homeassistant.config_entries",
|
||||||
|
|||||||
@@ -69,15 +69,17 @@ def _install_import_stubs() -> None:
|
|||||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||||
return _form(step_id, errors or {})
|
return _form(step_id, errors or {})
|
||||||
|
|
||||||
def async_show_menu(self, *, step_id, menu_options, sort=False,
|
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||||
description_placeholders=None, errors=None):
|
description_placeholders=None):
|
||||||
# t56: the real MENU result shape; the flow manager dispatches the
|
# t56: the real MENU result shape; the flow manager dispatches the
|
||||||
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
||||||
return {
|
return {
|
||||||
"type": "menu",
|
"type": "menu",
|
||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
"menu_options": list(menu_options),
|
"menu_options": list(menu_options),
|
||||||
"errors": dict(errors or {}),
|
"description_placeholders": dict(
|
||||||
|
description_placeholders or {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_create_entry(self, *, title, data, options=None):
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
|
|||||||
@@ -110,15 +110,17 @@ def _install_stubs() -> None:
|
|||||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||||
return _form(step_id, errors or {})
|
return _form(step_id, errors or {})
|
||||||
|
|
||||||
def async_show_menu(self, *, step_id, menu_options, sort=False,
|
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||||
description_placeholders=None, errors=None):
|
description_placeholders=None):
|
||||||
# t56: the real MENU result shape; the flow manager dispatches the
|
# t56: the real MENU result shape; the flow manager dispatches the
|
||||||
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
||||||
return {
|
return {
|
||||||
"type": "menu",
|
"type": "menu",
|
||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
"menu_options": list(menu_options),
|
"menu_options": list(menu_options),
|
||||||
"errors": dict(errors or {}),
|
"description_placeholders": dict(
|
||||||
|
description_placeholders or {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_create_entry(self, *, title, data, options=None):
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from homeassistant.config_entries import ( # noqa: E402
|
|||||||
OptionsFlowManager,
|
OptionsFlowManager,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant # noqa: E402
|
from homeassistant.core import HomeAssistant # noqa: E402
|
||||||
|
from homeassistant.data_entry_flow import FlowManager # noqa: E402
|
||||||
from homeassistant.helpers import config_validation as cv # noqa: E402
|
from homeassistant.helpers import config_validation as cv # noqa: E402
|
||||||
from homeassistant.helpers import device_registry as device_registry # noqa: E402
|
from homeassistant.helpers import device_registry as device_registry # noqa: E402
|
||||||
from homeassistant.helpers import entity_registry as er # noqa: E402
|
from homeassistant.helpers import entity_registry as er # noqa: E402
|
||||||
@@ -167,6 +168,54 @@ def make_entry(hass, options=None):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SetupManager(FlowManager):
|
||||||
|
"""A real FlowManager that builds the integration's real config flow.
|
||||||
|
|
||||||
|
Overrides only the handler construction, and does there what core does at
|
||||||
|
config_entries.py:1959 — the flow's ``init_step`` is the SOURCE, which is
|
||||||
|
why a real config flow starts at ``async_step_user`` and not at ``init``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def async_create_flow(self, handler_key, *, context=None, data=None):
|
||||||
|
flow = cf.NvidiaShieldConfigFlow()
|
||||||
|
flow.init_step = (context or {}).get("source", SOURCE_USER)
|
||||||
|
return flow
|
||||||
|
|
||||||
|
async def async_finish_flow(self, flow, result):
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def setup_manager(hass):
|
||||||
|
return SetupManager(hass)
|
||||||
|
|
||||||
|
|
||||||
|
async def open_setup(hass):
|
||||||
|
"""Start a real user-initiated setup flow and return (manager, result)."""
|
||||||
|
manager = setup_manager(hass)
|
||||||
|
result = await manager.async_init(
|
||||||
|
"nvidia_shield", context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
return manager, result
|
||||||
|
|
||||||
|
|
||||||
|
#: The first setup form, as the real frontend submits it: EVERY rendered field.
|
||||||
|
def first_form(**overrides):
|
||||||
|
payload = {
|
||||||
|
"name": "Shield",
|
||||||
|
"jellyfin_entity": "",
|
||||||
|
"receiver_entity": "",
|
||||||
|
"receiver_input": "",
|
||||||
|
"power_entity": "",
|
||||||
|
"adb_enabled": False,
|
||||||
|
"adb_store_app_info": True,
|
||||||
|
"host": "10.0.0.9",
|
||||||
|
"adb_port": 5555,
|
||||||
|
"protocol_enabled": False,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
class OptionsManager(OptionsFlowManager):
|
class OptionsManager(OptionsFlowManager):
|
||||||
"""OptionsFlowManager that builds the integration's real options flow.
|
"""OptionsFlowManager that builds the integration's real options flow.
|
||||||
|
|
||||||
@@ -308,39 +357,64 @@ class TestRealScriptClearing:
|
|||||||
"""The live bug: what the REAL flow stores when a selector is cleared."""
|
"""The live bug: what the REAL flow stores when a selector is cleared."""
|
||||||
|
|
||||||
async def test_set_then_cleared_persists_through_save_and_reopen(self, ha):
|
async def test_set_then_cleared_persists_through_save_and_reopen(self, ha):
|
||||||
"""t56 unit 0: SET, CLEAR, SAVE, REOPEN — the live symptom, end to end.
|
"""t56 unit 0 / t57 F4: SET, CLEAR, SAVE, REOPEN — the live symptom.
|
||||||
|
|
||||||
Scoped deliberately: the whole Scripts page goes through the real flow
|
The whole page goes through the real flow manager: first with a script
|
||||||
manager with every selector cleared (which is the shape the live bug was
|
SELECTED (an entity id, which the seeded registry resolves), then with
|
||||||
about), and the SET half is proven separately against the real selector
|
that one selector cleared while the other keeps its value. Both halves
|
||||||
objects in TestRealSelectorValidation below. A page carrying ENTITY IDS
|
are driven here — the earlier version of this file claimed entity ids
|
||||||
cannot be driven here — real HA validates an entity selector against the
|
could not be driven, which was simply false (the receiver page in this
|
||||||
loaded entity/device registries and a stubbed core refuses the ids; the
|
file has always submitted ``switch.soundbar``).
|
||||||
selector-object test is the honest place for that half.
|
|
||||||
"""
|
"""
|
||||||
entry = make_entry(ha)
|
entry = make_entry(ha)
|
||||||
manager, scripts = await open_scripts(ha, entry)
|
manager, scripts = await open_scripts(ha, entry)
|
||||||
|
|
||||||
# 1. CLEAR: the frontend submits the whole page, all selectors empty
|
# 1. SET: two commands selected through the real flow, from real ids
|
||||||
cleared = full_script_page("")
|
page = full_script_page("")
|
||||||
after_clear = await manager.async_configure(scripts["flow_id"], cleared)
|
page["script_turn_on"] = "script.kept"
|
||||||
assert after_clear["type"] == "menu", after_clear
|
page["script_volume_set"] = SCRIPTS["volume_set"]
|
||||||
|
after_set = await manager.async_configure(scripts["flow_id"], page)
|
||||||
|
assert after_set["type"] == "menu", after_set
|
||||||
|
|
||||||
created = await manager.async_configure(
|
created = await manager.async_configure(
|
||||||
after_clear["flow_id"], {"next_step_id": "finish"}
|
after_set["flow_id"], {"next_step_id": "finish"}
|
||||||
)
|
)
|
||||||
assert created["type"] == "create_entry"
|
assert created["type"] == "create_entry"
|
||||||
assert created["data"]["command_scripts"] == {}
|
assert created["data"]["command_scripts"] == {
|
||||||
|
"turn_on": "script.kept",
|
||||||
|
"volume_set": SCRIPTS["volume_set"],
|
||||||
|
}
|
||||||
|
|
||||||
# 2. PERSIST: re-open the page over the SAVED options — the cleared
|
# 2. CLEAR: the same page with ONE selector emptied (a cleared selector
|
||||||
# commands must not come back, and no flat script_* key was stored
|
# arrives as ''), the other left set
|
||||||
saved = dict(entry.options)
|
saved = dict(entry.options)
|
||||||
saved.update(created["data"])
|
saved.update(created["data"])
|
||||||
object.__setattr__(entry, "_options", saved)
|
object.__setattr__(entry, "_options", saved)
|
||||||
assert not [k for k in entry.options if k.startswith(SCRIPT_FIELD_PREFIX)]
|
|
||||||
manager2, scripts2 = await open_scripts(ha, entry)
|
manager2, scripts2 = await open_scripts(ha, entry)
|
||||||
values = suggested(scripts2["data_schema"])
|
assert suggested(scripts2["data_schema"])["script_turn_on"] == "script.kept"
|
||||||
assert not [v for k, v in values.items() if k.startswith(SCRIPT_FIELD_PREFIX) and v]
|
|
||||||
|
cleared = full_script_page("")
|
||||||
|
cleared["script_turn_on"] = ""
|
||||||
|
cleared["script_volume_set"] = SCRIPTS["volume_set"]
|
||||||
|
after_clear = await manager2.async_configure(scripts2["flow_id"], cleared)
|
||||||
|
assert after_clear["type"] == "menu", after_clear
|
||||||
|
created2 = await manager2.async_configure(
|
||||||
|
after_clear["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
assert created2["data"]["command_scripts"] == {
|
||||||
|
"volume_set": SCRIPTS["volume_set"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. PERSIST: re-open over the SAVED options — the cleared command must
|
||||||
|
# not come back, and no flat script_* key was ever stored
|
||||||
|
saved2 = dict(entry.options)
|
||||||
|
saved2.update(created2["data"])
|
||||||
|
object.__setattr__(entry, "_options", saved2)
|
||||||
|
assert not [k for k in entry.options if k.startswith(SCRIPT_FIELD_PREFIX)]
|
||||||
|
manager3, scripts3 = await open_scripts(ha, entry)
|
||||||
|
values = suggested(scripts3["data_schema"])
|
||||||
|
assert values["script_volume_set"] == SCRIPTS["volume_set"]
|
||||||
|
assert values.get("script_turn_on") in (None, "")
|
||||||
|
|
||||||
@pytest.mark.parametrize("shape", ["empty_string", "null"])
|
@pytest.mark.parametrize("shape", ["empty_string", "null"])
|
||||||
async def test_a_cleared_selector_is_accepted_by_the_real_schema(self, ha, shape):
|
async def test_a_cleared_selector_is_accepted_by_the_real_schema(self, ha, shape):
|
||||||
@@ -371,12 +445,12 @@ class TestRealScriptClearing:
|
|||||||
|
|
||||||
|
|
||||||
class TestRealSelectorValidation:
|
class TestRealSelectorValidation:
|
||||||
"""The ONE place the real selectors are exercised with real entity ids.
|
"""The selector distinction, pinned on the selector classes themselves.
|
||||||
|
|
||||||
Driving an entity-valued page through a stubbed FlowManager is not possible
|
``TestRealScriptClearing`` drives a cleared page end to end; this class pins
|
||||||
(see TestRealScriptClearing), so the distinction this task turns on is pinned
|
the underlying difference in isolation, so a regression says WHICH rule broke:
|
||||||
here directly against the real selector classes: the built-in EntitySelector
|
the built-in EntitySelector rejects a cleared field, ours accepts it and
|
||||||
rejects a cleared field, ours accepts it and still validates a real id.
|
still validates a real id.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_real_entity_selector_rejects_a_cleared_field(self):
|
def test_real_entity_selector_rejects_a_cleared_field(self):
|
||||||
@@ -480,3 +554,264 @@ class TestRealReceiverSelector:
|
|||||||
)
|
)
|
||||||
assert created["data"]["receiver_entity"] == "switch.soundbar"
|
assert created["data"]["receiver_entity"] == "switch.soundbar"
|
||||||
assert created["data"]["receiver_input"] == "HDMI 2"
|
assert created["data"]["receiver_input"] == "HDMI 2"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealSetupMenu:
|
||||||
|
"""t57 UNIT 0a / F2: the SETUP flow under a REAL FlowManager.
|
||||||
|
|
||||||
|
The stub suite drove `async_step_menu` directly, which is exactly how a
|
||||||
|
broken menu stays green: it never asked Home Assistant to dispatch a row.
|
||||||
|
These tests do, for every row, and they are the red proof for the TypeError
|
||||||
|
the first form used to hit on the way to the menu.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _no_connect(result=True):
|
||||||
|
async def _connect(*args, **kwargs):
|
||||||
|
return result
|
||||||
|
|
||||||
|
return staticmethod(_connect)
|
||||||
|
|
||||||
|
async def test_the_first_form_leads_into_pairing_then_the_menu(self, ha, monkeypatch):
|
||||||
|
"""The whole ordinary path: first form → ADB pairing → the menu."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
assert form["type"] == "form"
|
||||||
|
assert form["step_id"] == "user"
|
||||||
|
|
||||||
|
pairing = await manager.async_configure(
|
||||||
|
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||||
|
)
|
||||||
|
assert pairing["type"] == "form"
|
||||||
|
assert pairing["step_id"] == "adb_pair"
|
||||||
|
|
||||||
|
result = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||||
|
|
||||||
|
assert result["type"] == "menu", result
|
||||||
|
# ADB is paired, the protocol is off: only the two sub-screens + finish
|
||||||
|
assert list(result["menu_options"]) == ["receiver", "connection", "finish"]
|
||||||
|
|
||||||
|
async def test_the_adb_skip_reaches_the_menu(self, ha, monkeypatch):
|
||||||
|
"""The path that used to die: skip ADB pairing, then render the menu."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(False)
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
pairing = await manager.async_configure(
|
||||||
|
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||||
|
)
|
||||||
|
assert pairing["step_id"] == "adb_pair"
|
||||||
|
|
||||||
|
result = await manager.async_configure(
|
||||||
|
pairing["flow_id"], {"skip_pairing": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "menu", result
|
||||||
|
assert "adb_pair" in result["menu_options"] # not paired: still offered
|
||||||
|
|
||||||
|
async def test_adb_pairing_success_stops_offering_that_row(self, ha, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
pairing = await manager.async_configure(
|
||||||
|
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||||
|
)
|
||||||
|
paired = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||||
|
|
||||||
|
assert paired["type"] == "menu"
|
||||||
|
assert "adb_pair" not in paired["menu_options"] # F2: the guard
|
||||||
|
|
||||||
|
async def test_every_setup_menu_row_dispatches_and_renders(self, ha, monkeypatch):
|
||||||
|
"""Every row HA offers must have a handler AND render a form whose
|
||||||
|
step_id is that same row, or the page dead-ends on submit."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(False)
|
||||||
|
)
|
||||||
|
# the protocol row builds the androidtvremote2 client; this test is about
|
||||||
|
# DISPATCH, and the pairing internals have their own stub coverage. The
|
||||||
|
# UNREACHABLE branch is a real code path needing no library, so the row
|
||||||
|
# renders its real form.
|
||||||
|
async def _unreachable(self):
|
||||||
|
return None, "protocol_unreachable"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.NvidiaShieldConfigFlow, "_async_begin_protocol_pairing", _unreachable
|
||||||
|
)
|
||||||
|
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
menu_result = await manager.async_configure(
|
||||||
|
form["flow_id"],
|
||||||
|
first_form(adb_enabled=True, protocol_enabled=True),
|
||||||
|
)
|
||||||
|
assert menu_result["type"] == "form" # the ADB pairing form comes first
|
||||||
|
|
||||||
|
# hand the ADB form a skip so the MENU renders with every row
|
||||||
|
menu_result = await manager.async_configure(
|
||||||
|
menu_result["flow_id"], {"skip_pairing": True}
|
||||||
|
)
|
||||||
|
assert menu_result["type"] == "menu", menu_result
|
||||||
|
rows = list(menu_result["menu_options"])
|
||||||
|
assert rows == ["receiver", "connection", "adb_pair", "protocol_pair", "finish"]
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
# each row on its own flow (a flow is at one step at a time) AND on
|
||||||
|
# the manager that owns it: a flow lives in its manager's progress
|
||||||
|
row_manager, fresh = await open_setup(ha)
|
||||||
|
pairing = await row_manager.async_configure(
|
||||||
|
fresh["flow_id"], first_form(adb_enabled=True, protocol_enabled=True)
|
||||||
|
)
|
||||||
|
menu = await row_manager.async_configure(
|
||||||
|
pairing["flow_id"], {"skip_pairing": True}
|
||||||
|
)
|
||||||
|
opened = await row_manager.async_configure(
|
||||||
|
menu["flow_id"], {"next_step_id": row}
|
||||||
|
)
|
||||||
|
assert opened["type"] in ("form", "menu", "create_entry"), (row, opened)
|
||||||
|
if opened["type"] == "form":
|
||||||
|
assert opened["step_id"] == row, (row, opened["step_id"])
|
||||||
|
|
||||||
|
async def test_a_setup_sub_screen_submission_is_accepted(self, ha, monkeypatch):
|
||||||
|
"""A rendered sub-screen must be re-submittable (row id == step_id)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
pairing = await manager.async_configure(
|
||||||
|
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||||
|
)
|
||||||
|
menu = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||||
|
assert menu["type"] == "menu"
|
||||||
|
|
||||||
|
opened = await manager.async_configure(
|
||||||
|
menu["flow_id"], {"next_step_id": "receiver"}
|
||||||
|
)
|
||||||
|
assert opened["type"] == "form"
|
||||||
|
assert opened["step_id"] == "receiver"
|
||||||
|
|
||||||
|
back = await manager.async_configure(
|
||||||
|
opened["flow_id"], {"receiver_entity": "switch.soundbar"}
|
||||||
|
)
|
||||||
|
assert back["type"] == "menu", back
|
||||||
|
|
||||||
|
created = await manager.async_configure(
|
||||||
|
back["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
assert created["type"] == "create_entry"
|
||||||
|
assert created["options"]["receiver_entity"] == "switch.soundbar"
|
||||||
|
|
||||||
|
async def test_the_full_menu_walk_merges_and_creates_one_entry(self, ha, monkeypatch):
|
||||||
|
"""connection + receiver + finish → ONE entry carrying both edits."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
pairing = await manager.async_configure(
|
||||||
|
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||||
|
)
|
||||||
|
menu = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||||
|
|
||||||
|
connection = await manager.async_configure(
|
||||||
|
menu["flow_id"], {"next_step_id": "connection"}
|
||||||
|
)
|
||||||
|
assert connection["step_id"] == "connection"
|
||||||
|
menu2 = await manager.async_configure(
|
||||||
|
connection["flow_id"], {"host": "10.0.0.55", "adb_port": 5555,
|
||||||
|
"adb_enabled": True, "protocol_enabled": False}
|
||||||
|
)
|
||||||
|
assert menu2["type"] == "menu"
|
||||||
|
|
||||||
|
receiver = await manager.async_configure(
|
||||||
|
menu2["flow_id"], {"next_step_id": "receiver"}
|
||||||
|
)
|
||||||
|
menu3 = await manager.async_configure(
|
||||||
|
receiver["flow_id"], {"receiver_entity": "switch.soundbar"}
|
||||||
|
)
|
||||||
|
|
||||||
|
created = await manager.async_configure(
|
||||||
|
menu3["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
assert created["type"] == "create_entry"
|
||||||
|
assert created["options"]["host"] == "10.0.0.55"
|
||||||
|
assert created["options"]["receiver_entity"] == "switch.soundbar"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("adb_enabled", "protocol_enabled", "connect", "pairs_protocol"),
|
||||||
|
[
|
||||||
|
(True, False, False, False), # ADB skipped
|
||||||
|
(True, False, True, False), # ADB paired, protocol off
|
||||||
|
(True, True, False, False), # ADB skipped, protocol skipped
|
||||||
|
(True, True, True, False), # ADB paired, protocol skipped
|
||||||
|
(False, True, False, False), # protocol only
|
||||||
|
],
|
||||||
|
ids=["adb-skip", "adb-paired", "adb-skip+protocol-skip",
|
||||||
|
"pair-adb", "protocol-only"],
|
||||||
|
)
|
||||||
|
async def test_every_ordinary_path_reaches_create_entry(
|
||||||
|
self, ha, monkeypatch, adb_enabled, protocol_enabled, connect, pairs_protocol
|
||||||
|
):
|
||||||
|
"""t57 UNIT 0a: the path map.
|
||||||
|
|
||||||
|
Under the pre-fix tree FOUR of these five died with
|
||||||
|
`TypeError: async_show_menu() got an unexpected keyword argument
|
||||||
|
'errors'` the moment the ADB pairing form was submitted or skipped —
|
||||||
|
so a new entry could not be created on any path that went near the
|
||||||
|
menu, and the sub-screens were unreachable. Each path is driven here to
|
||||||
|
`create_entry`.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(connect)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _unreachable(self):
|
||||||
|
return None, "protocol_unreachable"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.NvidiaShieldConfigFlow, "_async_begin_protocol_pairing", _unreachable
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
result = await manager.async_configure(
|
||||||
|
form["flow_id"],
|
||||||
|
first_form(adb_enabled=adb_enabled, protocol_enabled=protocol_enabled),
|
||||||
|
)
|
||||||
|
assert result["type"] == "form", result
|
||||||
|
|
||||||
|
for _ in range(12):
|
||||||
|
if result["type"] == "create_entry":
|
||||||
|
break
|
||||||
|
step = result.get("step_id")
|
||||||
|
if step == "adb_pair":
|
||||||
|
result = await manager.async_configure(
|
||||||
|
result["flow_id"], {"skip_pairing": True}
|
||||||
|
)
|
||||||
|
elif step == "protocol_pair":
|
||||||
|
result = await manager.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{"pairing_code": "1234", "host": "10.0.0.9", "skip_pairing": True},
|
||||||
|
)
|
||||||
|
elif result["type"] == "menu":
|
||||||
|
# the menu must always offer a way out
|
||||||
|
assert "finish" in result["menu_options"], result["menu_options"]
|
||||||
|
result = await manager.async_configure(
|
||||||
|
result["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
else: # pragma: no cover - the assertion below reports it
|
||||||
|
break
|
||||||
|
|
||||||
|
assert result["type"] == "create_entry", result
|
||||||
|
|
||||||
|
async def test_the_menu_description_reports_what_is_unpaired(self, ha, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(False)
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
pairing = await manager.async_configure(
|
||||||
|
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=True)
|
||||||
|
)
|
||||||
|
menu = await manager.async_configure(pairing["flow_id"], {"skip_pairing": True})
|
||||||
|
|
||||||
|
assert menu["type"] == "menu"
|
||||||
|
note = (menu.get("description_placeholders") or {}).get("pairing", "")
|
||||||
|
assert "ADB" in note and "remote protocol" in note
|
||||||
+102
-14
@@ -18,6 +18,7 @@ stayed green while the form rendered blank.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
@@ -108,8 +109,8 @@ def _install_stubs() -> None:
|
|||||||
"description_placeholders": dict(description_placeholders or {}),
|
"description_placeholders": dict(description_placeholders or {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_show_menu(self, *, step_id, menu_options, sort=False,
|
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||||
description_placeholders=None, errors=None):
|
description_placeholders=None):
|
||||||
# t56: the real result shape. The flow manager reads
|
# t56: the real result shape. The flow manager reads
|
||||||
# `next_step_id` from the MENU result and calls
|
# `next_step_id` from the MENU result and calls
|
||||||
# `async_step_<choice>(None)`, so the stub reports the menu and the
|
# `async_step_<choice>(None)`, so the stub reports the menu and the
|
||||||
@@ -118,7 +119,9 @@ def _install_stubs() -> None:
|
|||||||
"type": "menu",
|
"type": "menu",
|
||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
"menu_options": list(menu_options),
|
"menu_options": list(menu_options),
|
||||||
"errors": dict(errors or {}),
|
"description_placeholders": dict(
|
||||||
|
description_placeholders or {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_create_entry(self, *, title, data, options=None):
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
@@ -200,15 +203,17 @@ def _install_stubs() -> None:
|
|||||||
"description_placeholders": dict(description_placeholders or {}),
|
"description_placeholders": dict(description_placeholders or {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_show_menu(self, *, step_id, menu_options, sort=False,
|
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||||
description_placeholders=None, errors=None):
|
description_placeholders=None):
|
||||||
# t56: the real result shape (data_entry_flow sets type "menu" from
|
# t56: the real result shape (data_entry_flow sets type "menu" from
|
||||||
# FlowResultType.MENU, which is a StrEnum — "menu" on the wire).
|
# FlowResultType.MENU, which is a StrEnum — "menu" on the wire).
|
||||||
return {
|
return {
|
||||||
"type": "menu",
|
"type": "menu",
|
||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
"menu_options": list(menu_options),
|
"menu_options": list(menu_options),
|
||||||
"errors": dict(errors or {}),
|
"description_placeholders": dict(
|
||||||
|
description_placeholders or {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SuggestedMixin:
|
class _SuggestedMixin:
|
||||||
@@ -933,7 +938,7 @@ class TestNoDeadEndPairing:
|
|||||||
# t56: the skip hands over to the setup MENU, and the unpaired protocol
|
# t56: the skip hands over to the setup MENU, and the unpaired protocol
|
||||||
# is still one row away — the escape hatch is intact, not a dead end
|
# is still one row away — the escape hatch is intact, not a dead end
|
||||||
assert result["type"] == "menu"
|
assert result["type"] == "menu"
|
||||||
assert "protocol" in result["menu_options"]
|
assert "protocol_pair" in result["menu_options"]
|
||||||
assert (await flow.async_step_protocol_pair(None))["step_id"] == "protocol_pair"
|
assert (await flow.async_step_protocol_pair(None))["step_id"] == "protocol_pair"
|
||||||
|
|
||||||
async def test_skip_protocol_after_skip_adb_creates_the_entry(
|
async def test_skip_protocol_after_skip_adb_creates_the_entry(
|
||||||
@@ -983,7 +988,7 @@ class TestNoDeadEndPairing:
|
|||||||
# t56: a finished pairing returns to the setup menu (the just-paired
|
# t56: a finished pairing returns to the setup menu (the just-paired
|
||||||
# transport is no longer offered), and the menu's Finish creates the entry
|
# transport is no longer offered), and the menu's Finish creates the entry
|
||||||
assert paired["type"] == "menu"
|
assert paired["type"] == "menu"
|
||||||
assert "protocol" not in paired["menu_options"]
|
assert "protocol_pair" not in paired["menu_options"]
|
||||||
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
||||||
assert flow._unique_id == FAKE_MAC
|
assert flow._unique_id == FAKE_MAC
|
||||||
|
|
||||||
@@ -1337,7 +1342,7 @@ class TestUnreachableShieldDuringPairing:
|
|||||||
|
|
||||||
skipped = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True})
|
skipped = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True})
|
||||||
assert skipped["type"] == "menu"
|
assert skipped["type"] == "menu"
|
||||||
assert "protocol" in skipped["menu_options"]
|
assert "protocol_pair" in skipped["menu_options"]
|
||||||
|
|
||||||
# choosing it re-renders the reachable-again pairing form with the
|
# choosing it re-renders the reachable-again pairing form with the
|
||||||
# unreachable error and both escapes, exactly as before
|
# unreachable error and both escapes, exactly as before
|
||||||
@@ -1463,14 +1468,30 @@ class TestCopyContract:
|
|||||||
assert aborted <= set(file["config"]["abort"])
|
assert aborted <= set(file["config"]["abort"])
|
||||||
|
|
||||||
async def test_every_rendered_setup_field_has_a_label(self, tmp_path, monkeypatch):
|
async def test_every_rendered_setup_field_has_a_label(self, tmp_path, monkeypatch):
|
||||||
|
"""t57: the setup flow's steps AND its menu rows carry labels.
|
||||||
|
|
||||||
|
The menu rows are read from ``menu_options`` — the key core uses — so a
|
||||||
|
label under a bare ``menu`` key (which never reaches the browser) fails
|
||||||
|
here rather than surfacing as a row called "connection" in the UI.
|
||||||
|
"""
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
_patch_protocol(monkeypatch, module)
|
_patch_protocol(monkeypatch, module)
|
||||||
flow, _hass = _new_flow(module, tmp_path)
|
flow, _hass = _new_flow(module, tmp_path)
|
||||||
|
await flow.async_step_user(
|
||||||
|
_user_input(**{CONF_ADB_ENABLED: True, CONF_PROTOCOL_ENABLED: True})
|
||||||
|
)
|
||||||
|
menu = await flow.async_step_menu(None)
|
||||||
|
|
||||||
rendered = {
|
rendered = {
|
||||||
"user": vol_mirror.declared_fields(
|
"user": vol_mirror.declared_fields(
|
||||||
(await flow.async_step_user(None))["data_schema"]
|
(await flow.async_step_user(None))["data_schema"]
|
||||||
),
|
),
|
||||||
|
"receiver": vol_mirror.declared_fields(
|
||||||
|
(await flow.async_step_receiver(None))["data_schema"]
|
||||||
|
),
|
||||||
|
"connection": vol_mirror.declared_fields(
|
||||||
|
(await flow.async_step_connection(None))["data_schema"]
|
||||||
|
),
|
||||||
"adb_pair": vol_mirror.declared_fields(
|
"adb_pair": vol_mirror.declared_fields(
|
||||||
(await flow.async_step_adb_pair(None))["data_schema"]
|
(await flow.async_step_adb_pair(None))["data_schema"]
|
||||||
),
|
),
|
||||||
@@ -1481,6 +1502,11 @@ class TestCopyContract:
|
|||||||
|
|
||||||
for file in (_strings(), _en_strings()):
|
for file in (_strings(), _en_strings()):
|
||||||
steps = file["config"]["step"]
|
steps = file["config"]["step"]
|
||||||
|
menu_step = steps["menu"]
|
||||||
|
assert menu_step["description"]
|
||||||
|
assert "menu" not in menu_step, "setup menu uses a bare 'menu' key"
|
||||||
|
for row in menu["menu_options"]:
|
||||||
|
assert row in menu_step["menu_options"], f"setup menu row {row}"
|
||||||
for step, fields in rendered.items():
|
for step, fields in rendered.items():
|
||||||
assert steps[step]["title"], step
|
assert steps[step]["title"], step
|
||||||
assert steps[step]["description"], step
|
assert steps[step]["description"], step
|
||||||
@@ -1513,9 +1539,11 @@ class TestCopyContract:
|
|||||||
for file in (_strings(), _en_strings()):
|
for file in (_strings(), _en_strings()):
|
||||||
menu_step = file["options"]["step"]["init"]
|
menu_step = file["options"]["step"]["init"]
|
||||||
assert menu_step["description"]
|
assert menu_step["description"]
|
||||||
# every row the menu offers has a label (HA renders these as names)
|
# every row the menu offers has a label. HA reads these from
|
||||||
|
# `menu_options` (the key 56 core integrations use) — under a bare
|
||||||
|
# `menu` key the rows fall back to raw ids in the browser (t57 F3).
|
||||||
for option in menu["menu_options"]:
|
for option in menu["menu_options"]:
|
||||||
assert option in menu_step.get("menu", {}), f"menu row {option}"
|
assert option in menu_step.get("menu_options", {}), f"menu row {option}"
|
||||||
|
|
||||||
steps = file["options"]["step"]
|
steps = file["options"]["step"]
|
||||||
for step, fields in rendered.items():
|
for step, fields in rendered.items():
|
||||||
@@ -2323,19 +2351,79 @@ class TestSetupMenu:
|
|||||||
async def test_every_setup_row_has_a_handler_named_after_it(
|
async def test_every_setup_row_has_a_handler_named_after_it(
|
||||||
self, tmp_path, monkeypatch
|
self, tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
|
"""t57 F2: the rule HA enforces, checked for EVERY row.
|
||||||
|
|
||||||
|
Two halves, because a menu can break in two ways: a row with no handler
|
||||||
|
(UnknownStep the moment it is clicked) and a sub-screen whose rendered
|
||||||
|
``step_id`` is not its row id (the form cannot be submitted, because HA
|
||||||
|
dispatches a submission by its OWN step_id). The pre-fix tree failed
|
||||||
|
both — rows named ``adb``/``protocol`` with handlers called
|
||||||
|
``adb_pair``/``protocol_pair``, and forms rendering ``setup_receiver``.
|
||||||
|
"""
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
_patch_adb(monkeypatch, module)
|
_patch_adb(monkeypatch, module)
|
||||||
flow, _hass = _new_flow(module, tmp_path)
|
flow, _hass = _new_flow(module, tmp_path)
|
||||||
|
# both transports on, so the MENU offers the pairing rows too
|
||||||
await flow.async_step_user(
|
await flow.async_step_user(
|
||||||
_user_input(**{CONF_ADB_ENABLED: False, CONF_PROTOCOL_ENABLED: False})
|
_user_input(**{CONF_ADB_ENABLED: True, CONF_PROTOCOL_ENABLED: True})
|
||||||
)
|
)
|
||||||
|
|
||||||
menu = await flow.async_step_menu(None)
|
menu = await flow.async_step_menu(None)
|
||||||
|
|
||||||
for row in menu["menu_options"]:
|
assert list(menu["menu_options"]) == [
|
||||||
assert hasattr(flow, f"async_step_{row}"), row
|
"receiver",
|
||||||
|
"connection",
|
||||||
|
"adb_pair",
|
||||||
|
"protocol_pair",
|
||||||
|
"finish",
|
||||||
|
]
|
||||||
# ...and the row ids never include the menu itself, which would loop
|
# ...and the row ids never include the menu itself, which would loop
|
||||||
assert "menu" not in menu["menu_options"]
|
assert "menu" not in menu["menu_options"]
|
||||||
|
for row in menu["menu_options"]:
|
||||||
|
assert hasattr(flow, f"async_step_{row}"), row
|
||||||
|
if row == "finish":
|
||||||
|
continue
|
||||||
|
form = await getattr(flow, f"async_step_{row}")(None)
|
||||||
|
assert form["type"] == "form"
|
||||||
|
assert form["step_id"] == row, (row, form["step_id"])
|
||||||
|
|
||||||
|
async def test_the_menu_stub_mirrors_the_real_signature(self):
|
||||||
|
"""t57 UNIT 0a systemic half: the stub must not accept anything.
|
||||||
|
|
||||||
|
The production bug was ``async_show_menu(..., errors=...)`` — a TypeError
|
||||||
|
under real HA — and the stub suite could not see it because every stub
|
||||||
|
took an ``errors`` keyword the real method does not have. The stub's
|
||||||
|
parameter set is pinned here, and the real-HA half is in
|
||||||
|
``ha_api_drift.HA_CALL_OWNERS`` (with a pre-fix source in
|
||||||
|
``tests/test_ha_api_drift.py`` proving it is non-vacuous).
|
||||||
|
"""
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow.__new__(module.NvidiaShieldConfigFlow)
|
||||||
|
params = set(
|
||||||
|
inspect.signature(type(flow).async_show_menu).parameters
|
||||||
|
)
|
||||||
|
assert params == {
|
||||||
|
"self",
|
||||||
|
"step_id",
|
||||||
|
"menu_options",
|
||||||
|
"sort",
|
||||||
|
"description_placeholders",
|
||||||
|
}, "the menu stub drifted from HA's real signature"
|
||||||
|
|
||||||
|
options_flow = module.NvidiaShieldOptionsFlow.__new__(
|
||||||
|
module.NvidiaShieldOptionsFlow
|
||||||
|
)
|
||||||
|
assert "errors" not in inspect.signature(
|
||||||
|
type(options_flow).async_show_menu
|
||||||
|
).parameters
|
||||||
|
|
||||||
|
async def test_both_menus_key_their_row_labels_under_menu_options(self, tmp_path):
|
||||||
|
"""t57 F3: a label under the wrong key never reaches the browser."""
|
||||||
|
for file in (_strings(), _en_strings()):
|
||||||
|
for section, step in (("config", "menu"), ("options", "init")):
|
||||||
|
block = file[section]["step"][step]
|
||||||
|
assert "menu" not in block, f"{section}.step.{step} uses a bare 'menu' key"
|
||||||
|
assert block["menu_options"], f"{section}.step.{step} has no row labels"
|
||||||
|
|
||||||
async def test_connection_and_receiver_edits_both_land_in_the_entry(
|
async def test_connection_and_receiver_edits_both_land_in_the_entry(
|
||||||
self, tmp_path, monkeypatch
|
self, tmp_path, monkeypatch
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ PRE_FIX_SOURCES = {
|
|||||||
" async def async_step_zeroconf(self, discovery_info):\n"
|
" async def async_step_zeroconf(self, discovery_info):\n"
|
||||||
" self._async_handle_discovery_without_unique_id()\n"
|
" self._async_handle_discovery_without_unique_id()\n"
|
||||||
),
|
),
|
||||||
|
# t57 UNIT 0a: a wrong kwarg on a FLOW-DISPLAY API. `async_show_menu` takes
|
||||||
|
# no `errors` in HA 2026.10.1, and the integration passed one — so every
|
||||||
|
# ordinary setup path raised TypeError at the moment a user submitted the
|
||||||
|
# first form, which is invisible to a stub suite whose stubs accept it.
|
||||||
|
"t57_show_menu_errors.py": (
|
||||||
|
"from homeassistant import config_entries\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"class PreFixSetupMenu(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
|
||||||
|
" async def async_step_user(self, user_input=None):\n"
|
||||||
|
" return self.async_show_menu(\n"
|
||||||
|
" step_id='menu',\n"
|
||||||
|
" menu_options=['finish'],\n"
|
||||||
|
" errors={\'base\': \'nope\'},\n"
|
||||||
|
" )\n"
|
||||||
|
),
|
||||||
"t35_options_updates.py": (
|
"t35_options_updates.py": (
|
||||||
"from homeassistant import config_entries\n"
|
"from homeassistant import config_entries\n"
|
||||||
"\n"
|
"\n"
|
||||||
@@ -106,6 +122,7 @@ def test_the_sweep_flags_the_pre_fix_defects(tmp_path):
|
|||||||
("d2_util_diagnostic.py", "broken-import"),
|
("d2_util_diagnostic.py", "broken-import"),
|
||||||
("d3_unawaited_discovery.py", "missing-await"),
|
("d3_unawaited_discovery.py", "missing-await"),
|
||||||
("t35_options_updates.py", "unknown-kwarg"),
|
("t35_options_updates.py", "unknown-kwarg"),
|
||||||
|
("t57_show_menu_errors.py", "unknown-kwarg"),
|
||||||
}, "\n" + str(report)
|
}, "\n" + str(report)
|
||||||
|
|
||||||
|
|
||||||
@@ -290,6 +307,12 @@ _REAL_HA_MATRIX = (
|
|||||||
{"jellyfin_entity": "media_player.jellyfin"},
|
{"jellyfin_entity": "media_player.jellyfin"},
|
||||||
"sensor.shield_now_playing",
|
"sensor.shield_now_playing",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"sensor",
|
||||||
|
"NvidiaShieldAppsSensor",
|
||||||
|
{"adb_enabled": True},
|
||||||
|
"sensor.shield_installed_apps",
|
||||||
|
),
|
||||||
("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"),
|
("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"),
|
||||||
("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"),
|
("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -103,15 +103,17 @@ def _install_stubs() -> None:
|
|||||||
"description_placeholders": dict(description_placeholders or {}),
|
"description_placeholders": dict(description_placeholders or {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_show_menu(self, *, step_id, menu_options, sort=False,
|
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||||
description_placeholders=None, errors=None):
|
description_placeholders=None):
|
||||||
# t56: the real MENU result shape — the flow manager dispatches the
|
# t56: the real MENU result shape — the flow manager dispatches the
|
||||||
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
||||||
return {
|
return {
|
||||||
"type": "menu",
|
"type": "menu",
|
||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
"menu_options": list(menu_options),
|
"menu_options": list(menu_options),
|
||||||
"errors": dict(errors or {}),
|
"description_placeholders": dict(
|
||||||
|
description_placeholders or {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_create_entry(self, *, title, data, options=None):
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
@@ -161,15 +163,17 @@ def _install_stubs() -> None:
|
|||||||
"description_placeholders": dict(description_placeholders or {}),
|
"description_placeholders": dict(description_placeholders or {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_show_menu(self, *, step_id, menu_options, sort=False,
|
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||||
description_placeholders=None, errors=None):
|
description_placeholders=None):
|
||||||
# t56: the real MENU result shape — the flow manager dispatches the
|
# t56: the real MENU result shape — the flow manager dispatches the
|
||||||
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
||||||
return {
|
return {
|
||||||
"type": "menu",
|
"type": "menu",
|
||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
"menu_options": list(menu_options),
|
"menu_options": list(menu_options),
|
||||||
"errors": dict(errors or {}),
|
"description_placeholders": dict(
|
||||||
|
description_placeholders or {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_create_entry(self, *, title, data):
|
def async_create_entry(self, *, title, data):
|
||||||
|
|||||||
Reference in new issue
Block a user