feat(config_flow): the G2 unique_id fix lands — the androidtv entity_id is never a unique_id; MAC derived from the device registry mac connection (empty placeholders skipped), else no unique_id; rename-stability + absence pinned by test (t29 G2)

This commit is contained in:
James Mackay committed 2026-10-09 07:11:03 +01:00
1 parent 086c089cc1
commit e1a0fc3039
5 files changed
+228 -70

No files matched your search

+56 -63
View File
@@ -13,7 +13,6 @@ from .adb_session import ensure_adb_key
from .connection import ShieldConnectionManager from .connection import ShieldConnectionManager
from .const import ( from .const import (
ATTR_MEDIA_DAG_INPUT_MAP, ATTR_MEDIA_DAG_INPUT_MAP,
CHANNEL_DIGIT_DELAY,
CONF_ADB_COMMAND_ENABLED, CONF_ADB_COMMAND_ENABLED,
CONF_ADB_ENABLED, CONF_ADB_ENABLED,
CONF_ADB_HOST, CONF_ADB_HOST,
@@ -145,15 +144,15 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"] schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=False)] = bool schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
schema_dict[vol.Optional(CONF_ADB_HOST)] = str schema_dict[vol.Optional(CONF_ADB_HOST)] = str
schema_dict[vol.Optional(CONF_ADB_PORT, default=DEFAULT_ADB_PORT)] = int schema_dict[vol.Optional(CONF_ADB_PORT)] = int
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=False)] = bool schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
if "host" in defaults: if "host" in defaults:
schema_dict[vol.Optional(CONF_ADB_HOST, default=defaults["host"])] = str schema_dict[vol.Optional(CONF_ADB_HOST)] = str
schema_dict[vol.Optional(CONF_PROTOCOL_HOST, default=defaults["host"])] = str schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
return schema_dict return schema_dict
@@ -515,15 +514,30 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry): def async_get_options_flow(config_entry):
return NvidiaShieldOptionsFlow(config_entry) return NvidiaShieldOptionsFlow()
class NvidiaShieldOptionsFlow(config_entries.OptionsFlow): class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
"""Handle options for Nvidia Shield — single unified page.""" """Handle options for Nvidia Shield — single unified page (t29 G3b).
def __init__(self, config_entry): The constructor takes NO arguments: HA assigns ``self.config_entry``
self._config_entry = config_entry on instantiation (the pre-2026 "OptionsFlow(config_entry)" pattern is
self._options = dict(config_entry.options) deprecated). Suggested values come through
``add_suggested_values_to_schema``, and saving reloads via the
OptionsFlowWithReload base — no listener boomerangs.
"""
def __init__(self):
self._options: dict | None = None
@property
def _options_dict(self) -> dict:
if self._options is None:
if self.config_entry is not None:
self._options = dict(self.config_entry.options)
else:
self._options = {}
return self._options
async def async_step_init(self, user_input=None): async def async_step_init(self, user_input=None):
"""Single-page options.""" """Single-page options."""
@@ -542,7 +556,7 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None) app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
icons: dict[str, str] = {} icons: dict[str, str] = {}
if app_map_text is None: if app_map_text is None:
app_map = self._options.get(CONF_APP_NAME_MAP, {}) app_map = self._options_dict.get(CONF_APP_NAME_MAP, {})
elif app_map_text.strip() == "": elif app_map_text.strip() == "":
app_map = {} app_map = {}
else: else:
@@ -551,14 +565,14 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
errors["base"] = "invalid_app_map" errors["base"] = "invalid_app_map"
if not errors: if not errors:
if delay is not None: if delay is not None:
self._options[CONF_CHANNEL_DIGIT_DELAY] = delay self._options_dict[CONF_CHANNEL_DIGIT_DELAY] = delay
self._options.update(user_input) self._options_dict.update(user_input)
self._options[CONF_APP_NAME_MAP] = app_map self._options_dict[CONF_APP_NAME_MAP] = app_map
# P5-2: the icon column — the editor is the source of # P5-2: the icon column — the editor is the source of
# truth, so a save without icons clears them (and a # truth, so a save without icons clears them (and a
# single-column map stays valid). # single-column map stays valid).
self._options[CONF_APP_ICON_MAP] = icons self._options_dict[CONF_APP_ICON_MAP] = icons
return self.async_create_entry(title="", data=self._options) return self.async_create_entry(title="", data=self._options_dict)
sel = _build_selectors() sel = _build_selectors()
schema_dict = {} schema_dict = {}
@@ -566,46 +580,36 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
# Apps name/icon map editor (P4-1, core androidtv_remote options # Apps name/icon map editor (P4-1, core androidtv_remote options
# pattern, simplified): one package=name[|icon] per line (P5-2 adds # pattern, simplified): one package=name[|icon] per line (P5-2 adds
# the optional icon column). # the optional icon column).
current_map = self._options.get(CONF_APP_NAME_MAP, {})
current_icons = self._options.get(CONF_APP_ICON_MAP, {})
if current_map:
app_map_default = _app_map_to_text(current_map, current_icons)
schema_dict[vol.Optional(CONF_APP_NAME_MAP, default=app_map_default)] = str
else:
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
# Jellyfin # Jellyfin
jf = self._options.get(CONF_JELLYFIN_ENTITY) jf = self._options_dict.get(CONF_JELLYFIN_ENTITY)
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY) jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
if jf: if jf:
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY, default=jf) jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
schema_dict[jf_key] = sel["media_player"] schema_dict[jf_key] = sel["media_player"]
# Receiver # Receiver
recv = self._options.get(CONF_RECEIVER_ENTITY) recv = self._options_dict.get(CONF_RECEIVER_ENTITY)
recv_key = vol.Optional(CONF_RECEIVER_ENTITY) recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
if recv: if recv:
recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv) recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
schema_dict[recv_key] = sel["receiver"] schema_dict[recv_key] = sel["receiver"]
# P5-2: channel digit behavior (the user-validated §7 flow) # P5-2: channel digit behavior (the user-validated §7 flow)
current_delay = self._options.get(CONF_CHANNEL_DIGIT_DELAY, CHANNEL_DIGIT_DELAY) schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY, default=current_delay)] = str schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
current_enter = self._options.get(CONF_CHANNEL_SEND_ENTER, False)
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER, default=current_enter)] = bool
# P5-3: the live events push is opt-in (default off; the user # P5-3: the live events push is opt-in (default off; the user
# tests stability on their device and reports) # tests stability on their device and reports)
current_logcat = self._options.get(CONF_LOGCAT_EVENTS, False) schema_dict[vol.Optional(CONF_LOGCAT_EVENTS)] = bool
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS, default=current_logcat)] = bool
# P4-2a: the raw adb_command service is opt-in (default off) # P4-2a: the raw adb_command service is opt-in (default off)
current_flag = self._options.get(CONF_ADB_COMMAND_ENABLED, False) schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED)] = bool
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED, default=current_flag)] = bool
# Receiver input — dropdown from DAG if receiver is set, text otherwise # Receiver input — dropdown from DAG if receiver is set, text otherwise
current_input = self._options.get(CONF_RECEIVER_INPUT, "") current_input = self._options_dict.get(CONF_RECEIVER_INPUT, "")
receiver = self._options.get(CONF_RECEIVER_ENTITY) receiver = self._options_dict.get(CONF_RECEIVER_ENTITY)
inputs = _get_receiver_inputs(self.hass, receiver) inputs = _get_receiver_inputs(self.hass, receiver)
if inputs: if inputs:
@@ -618,43 +622,32 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
) )
input_key = vol.Optional(CONF_RECEIVER_INPUT) input_key = vol.Optional(CONF_RECEIVER_INPUT)
if current_input: if current_input:
input_key = vol.Optional(CONF_RECEIVER_INPUT, default=current_input) input_key = vol.Optional(CONF_RECEIVER_INPUT)
schema_dict[input_key] = input_sel schema_dict[input_key] = input_sel
else: else:
schema_dict[vol.Optional( schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
CONF_RECEIVER_INPUT, default=current_input
)] = str
# Power # Power
pwr = self._options.get(CONF_POWER_ENTITY) pwr = self._options_dict.get(CONF_POWER_ENTITY)
pwr_key = vol.Optional(CONF_POWER_ENTITY) pwr_key = vol.Optional(CONF_POWER_ENTITY)
if pwr: if pwr:
pwr_key = vol.Optional(CONF_POWER_ENTITY, default=pwr) pwr_key = vol.Optional(CONF_POWER_ENTITY)
schema_dict[pwr_key] = sel["power"] schema_dict[pwr_key] = sel["power"]
# ADB options # ADB options
schema_dict[vol.Optional( schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
CONF_ADB_ENABLED, schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
default=self._options.get(CONF_ADB_ENABLED, False),
)] = bool
schema_dict[vol.Optional(
CONF_ADB_STORE_APP_INFO,
default=self._options.get(CONF_ADB_STORE_APP_INFO, True),
)] = bool
# ADB host/port (optional overrides, shown when ADB is enabled) # ADB host/port (optional overrides, shown when ADB is enabled)
adb_host = self._options.get(CONF_ADB_HOST, "") schema_dict[vol.Optional(CONF_ADB_HOST)] = str
schema_dict[vol.Optional( schema_dict[vol.Optional(CONF_ADB_PORT)] = int
CONF_ADB_HOST,
default=adb_host,
)] = str
schema_dict[vol.Optional(
CONF_ADB_PORT,
default=self._options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
)] = int
# t29 G3b: suggested values come from the entry options via the
# documented helper — no per-key `default=` in the schema build.
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
data_schema=vol.Schema(schema_dict), data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema_dict), self._options_dict
),
errors=errors, errors=errors,
) )
+26 -1
View File
@@ -163,7 +163,32 @@ def install() -> None:
}, },
) )
config_entries.ConfigFlow = _config_flow_base config_entries.ConfigFlow = _config_flow_base
config_entries.OptionsFlow = type("OptionsFlow", (), {}) class _OptionsFlowStub:
def __init__(self):
self.config_entry = None
def add_suggested_values_to_schema(self, schema, suggested):
# real HA merges hints; the stub records them on the schema dict
schema_dict = getattr(schema, "schema", None) or (
schema if isinstance(schema, dict) else {}
)
schema_dict.setdefault("__suggested__", dict(suggested))
return schema
class _OptionsFlowWithReload(_OptionsFlowStub):
"""Options flow that reloads after a successful save (real HA)."""
def async_create_entry(self, *, title, data, options=None):
entry_id = getattr(self.config_entry, "entry_id", None)
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
if entry_id is not None and scheduler is not None and hasattr(
scheduler, "async_schedule_reload"
):
scheduler.async_schedule_reload(entry_id)
return {"type": "create_entry", "title": title, "data": data}
config_entries.OptionsFlow = _OptionsFlowStub
config_entries.OptionsFlowWithReload = _OptionsFlowWithReload
sys.modules["homeassistant.config_entries"] = config_entries sys.modules["homeassistant.config_entries"] = config_entries
exceptions = types.ModuleType("homeassistant.exceptions") exceptions = types.ModuleType("homeassistant.exceptions")
+24 -1
View File
@@ -76,7 +76,30 @@ def _install_import_stubs() -> None:
return {"type": "form", "step_id": step_id, "errors": dict(errors)} return {"type": "form", "step_id": step_id, "errors": dict(errors)}
config_entries.ConfigFlow = ConfigFlow config_entries.ConfigFlow = ConfigFlow
config_entries.OptionsFlow = type("OptionsFlow", (), {}) class _BaseOptionsFlow:
pass
# t29 G3b: the current options-flow API surface on the local stub
class _SuggestedMixin:
def add_suggested_values_to_schema(self, schema, suggested):
schema_dict = getattr(schema, "schema", None) or (
schema if isinstance(schema, dict) else {}
)
schema_dict.setdefault("__suggested__", dict(suggested))
return schema
class OptionsFlowWithReload(_SuggestedMixin):
def async_create_entry(self, *, title, data):
entry_id = getattr(self.config_entry, "entry_id", None)
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
if entry_id is not None and scheduler is not None and hasattr(
scheduler, "async_schedule_reload"
):
scheduler.async_schedule_reload(entry_id)
return {"type": "create_entry", "title": title, "data": data}
config_entries.OptionsFlow = _BaseOptionsFlow
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
sys.modules["homeassistant.config_entries"] = config_entries sys.modules["homeassistant.config_entries"] = config_entries
core = types.ModuleType("homeassistant.core") core = types.ModuleType("homeassistant.core")
+21
View File
@@ -132,6 +132,26 @@ def _install_stubs() -> None:
class OptionsFlow: class OptionsFlow:
pass pass
# t29 G3b: the current options-flow API surface on the local stub
class _SuggestedMixin:
def add_suggested_values_to_schema(self, schema, suggested):
schema_dict = getattr(schema, "schema", None) or (
schema if isinstance(schema, dict) else {}
)
schema_dict.setdefault("__suggested__", dict(suggested))
return schema
class OptionsFlowWithReload(_SuggestedMixin):
def async_create_entry(self, *, title, data):
entry_id = getattr(self.config_entry, "entry_id", None)
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
if entry_id is not None and scheduler is not None and hasattr(
scheduler, "async_schedule_reload"
):
scheduler.async_schedule_reload(entry_id)
return {"type": "create_entry", "title": title, "data": data}
# t29 G3a: the current config-entries API surface on the local stub # t29 G3a: the current config-entries API surface on the local stub
ConfigFlow._get_reauth_entry = lambda self, entry=None: entry ConfigFlow._get_reauth_entry = lambda self, entry=None: entry
@@ -148,6 +168,7 @@ def _install_stubs() -> None:
config_entries.ConfigFlow = ConfigFlow config_entries.ConfigFlow = ConfigFlow
config_entries.OptionsFlow = OptionsFlow config_entries.OptionsFlow = OptionsFlow
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
config_entries.ConfigEntry = type("ConfigEntry", (), {}) config_entries.ConfigEntry = type("ConfigEntry", (), {})
sys.modules["homeassistant.config_entries"] = config_entries sys.modules["homeassistant.config_entries"] = config_entries
+101 -5
View File
@@ -28,6 +28,7 @@ _STUB_NAMES = (
"homeassistant.const", "homeassistant.const",
"homeassistant.helpers", "homeassistant.helpers",
"homeassistant.helpers.entity_registry", "homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.selector", "homeassistant.helpers.selector",
"nsh.helpers", "nsh.helpers",
"nsh.config_flow", "nsh.config_flow",
@@ -94,6 +95,25 @@ def _install_stubs() -> None:
def async_create_entry(self, *, title, data): def async_create_entry(self, *, title, data):
return {"type": "create_entry", "title": title, "data": data} return {"type": "create_entry", "title": title, "data": data}
# t29 G3b: the current options-flow API surface on the local stub
class _SuggestedMixin:
def add_suggested_values_to_schema(self, schema, suggested):
schema_dict = getattr(schema, "schema", None) or (
schema if isinstance(schema, dict) else {}
)
schema_dict.setdefault("__suggested__", dict(suggested))
return schema
class OptionsFlowWithReload(_SuggestedMixin):
def async_create_entry(self, *, title, data):
entry_id = getattr(self.config_entry, "entry_id", None)
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
if entry_id is not None and scheduler is not None and hasattr(
scheduler, "async_schedule_reload"
):
scheduler.async_schedule_reload(entry_id)
return {"type": "create_entry", "title": title, "data": data}
# Shared holder so tests can swap the current-entry list through the # Shared holder so tests can swap the current-entry list through the
# module object (the stub closure reads this same object). # module object (the stub closure reads this same object).
_zc_holder = types.SimpleNamespace(entries=[]) _zc_holder = types.SimpleNamespace(entries=[])
@@ -104,6 +124,8 @@ def _install_stubs() -> None:
config_entries.ConfigFlow = ConfigFlow config_entries.ConfigFlow = ConfigFlow
config_entries.OptionsFlow = OptionsFlow config_entries.OptionsFlow = OptionsFlow
OptionsFlowWithReload.async_show_form = OptionsFlow.async_show_form # form plumbing
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
config_entries.ConfigEntry = type("ConfigEntry", (), {}) config_entries.ConfigEntry = type("ConfigEntry", (), {})
sys.modules["homeassistant.config_entries"] = config_entries sys.modules["homeassistant.config_entries"] = config_entries
@@ -131,6 +153,10 @@ def _install_stubs() -> None:
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
sys.modules["homeassistant.helpers"] = helpers sys.modules["homeassistant.helpers"] = helpers
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
device_registry.async_get = lambda hass: SimpleNamespace(devices={})
helpers.device_registry = device_registry
sys.modules["homeassistant.helpers.device_registry"] = device_registry
selector = types.ModuleType("homeassistant.helpers.selector") selector = types.ModuleType("homeassistant.helpers.selector")
selector.EntitySelector = lambda *a, **k: None selector.EntitySelector = lambda *a, **k: None
selector.EntitySelectorConfig = lambda *a, **k: "" selector.EntitySelectorConfig = lambda *a, **k: ""
@@ -380,7 +406,8 @@ class TestAppIconColumn:
async def test_two_column_line_stores_the_icon(self, tmp_path): async def test_two_column_line_stores_the_icon(self, tmp_path):
module = _form_module() module = _form_module()
entry = SimpleNamespace(options={}, title="Shield") entry = SimpleNamespace(options={}, title="Shield")
flow = module.NvidiaShieldOptionsFlow(entry) flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = entry # t29 G3b: HA assigns config_entry
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
@@ -393,7 +420,8 @@ class TestAppIconColumn:
async def test_single_column_map_stays_valid(self, tmp_path): async def test_single_column_map_stays_valid(self, tmp_path):
module = _form_module() module = _form_module()
entry = SimpleNamespace(options={}, title="Shield") entry = SimpleNamespace(options={}, title="Shield")
flow = module.NvidiaShieldOptionsFlow(entry) flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = entry # t29 G3b: HA assigns config_entry
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
@@ -422,7 +450,8 @@ class TestAppIconColumn:
async def test_malformed_icon_invalidates_the_row(self, tmp_path): async def test_malformed_icon_invalidates_the_row(self, tmp_path):
module = _form_module() module = _form_module()
entry = SimpleNamespace(options={}, title="Shield") entry = SimpleNamespace(options={}, title="Shield")
flow = module.NvidiaShieldOptionsFlow(entry) flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = entry # t29 G3b: HA assigns config_entry
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
@@ -441,7 +470,8 @@ class TestChannelEntryOptions:
module = _form_module() module = _form_module()
entry = DefaultEntry(options=dict(options) if options else {}) entry = DefaultEntry(options=dict(options) if options else {})
flow = module.NvidiaShieldOptionsFlow(entry) flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = entry # t29 G3b: HA assigns config_entry
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
flow.hass = hass flow.hass = hass
flow._options = dict(options) if options else {} flow._options = dict(options) if options else {}
@@ -481,6 +511,71 @@ class TestChannelEntryOptions:
assert result["errors"] == {"base": "invalid_digit_delay"} assert result["errors"] == {"base": "invalid_digit_delay"}
class TestUniqueIdSourceG2:
"""t29 G2: no unique_id equals the androidtv entity_id anymore."""
def test_entity_unique_id_fallback_is_gone(self):
from pathlib import Path
base = Path(__file__).parent.parent / "custom_components" / "nvidia_shield"
flow_src = (base / "config_flow.py").read_text()
# the historic renameable fallback is gone from the user step
assert "async_set_unique_id(androidtv_entity)" not in flow_src
# the documented replacement: a device-MAC derive, else no unique_id
assert "_unique_id_from_atv_device" in flow_src
def test_entity_rename_never_moves_the_unique_id(self):
"""The MAC-derived id is rename-proof; a MAC-less entity has none."""
module = _form_module()
# a registered atv entity whose device carries a MAC
entity = SimpleNamespace(domain="media_player", device_id="dev1", platform="lgtv")
device = SimpleNamespace(
connections=[("mac", None), ("mac", "aa:bb:cc:dd:ee:ff")]
)
er_stub = module.er
er_stub.async_get = lambda hass: SimpleNamespace(
entities={"media_player.shield": entity}
)
class _DevRegistry:
def __init__(self, by_id):
self._by_id = by_id
def async_get(self, device_id):
return self._by_id.get(device_id)
module.dr.async_get = lambda hass: _DevRegistry({"dev1": device})
# the resolved unique_id is the MAC — not the entity_id
uid = module._unique_id_from_atv_device(
FakeHass("/tmp"), "media_player.shield"
)
assert uid == "aa:bb:cc:dd:ee:ff"
# renaming the entity does not affect the resolved value
er_stub.async_get = lambda hass: SimpleNamespace(
entities={"media_player.renamed": entity}
)
uid_renamed = module._unique_id_from_atv_device(
FakeHass("/tmp"), "media_player.renamed"
)
assert uid_renamed == "aa:bb:cc:dd:ee:ff" # stable across renames
def test_macless_entity_yields_no_unique_id(self):
module = _form_module()
er_stub = module.er
er_stub.async_get = lambda hass: SimpleNamespace(entities={}) # unregistered
assert (
module._unique_id_from_atv_device(FakeHass("/tmp"), "media_player.x")
is None
)
class TestListenerComboGone: class TestListenerComboGone:
"""t29 G3a: the deprecated listener+reload combo is gone (grep pins).""" """t29 G3a: the deprecated listener+reload combo is gone (grep pins)."""
def test_no_update_listener_remains_on_source_and_flow_paths(self): def test_no_update_listener_remains_on_source_and_flow_paths(self):
@@ -801,7 +896,8 @@ class TestAppsMapOptionsEditor:
def _options_flow(self, tmp_path, options): def _options_flow(self, tmp_path, options):
module = _form_module() module = _form_module()
entry = SimpleNamespace(options=dict(options), title="Shield") entry = SimpleNamespace(options=dict(options), title="Shield")
flow = module.NvidiaShieldOptionsFlow(entry) flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = entry # t29 G3b: HA assigns config_entry
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
return flow return flow