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:
1 parent
086c089cc1
commit
e1a0fc3039
5 files changed
+228
-70
No files matched your search
@@ -13,7 +13,6 @@ from .adb_session import ensure_adb_key
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
ATTR_MEDIA_DAG_INPUT_MAP,
|
||||
CHANNEL_DIGIT_DELAY,
|
||||
CONF_ADB_COMMAND_ENABLED,
|
||||
CONF_ADB_ENABLED,
|
||||
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_ADB_ENABLED, default=False)] = bool
|
||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
||||
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = 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_PORT, default=DEFAULT_ADB_PORT)] = int
|
||||
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=False)] = bool
|
||||
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
|
||||
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
|
||||
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
||||
if "host" in defaults:
|
||||
schema_dict[vol.Optional(CONF_ADB_HOST, default=defaults["host"])] = str
|
||||
schema_dict[vol.Optional(CONF_PROTOCOL_HOST, default=defaults["host"])] = str
|
||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
||||
return schema_dict
|
||||
|
||||
|
||||
@@ -515,15 +514,30 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
return NvidiaShieldOptionsFlow(config_entry)
|
||||
return NvidiaShieldOptionsFlow()
|
||||
|
||||
|
||||
class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Handle options for Nvidia Shield — single unified page."""
|
||||
class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||||
"""Handle options for Nvidia Shield — single unified page (t29 G3b).
|
||||
|
||||
def __init__(self, config_entry):
|
||||
self._config_entry = config_entry
|
||||
self._options = dict(config_entry.options)
|
||||
The constructor takes NO arguments: HA assigns ``self.config_entry``
|
||||
on instantiation (the pre-2026 "OptionsFlow(config_entry)" pattern is
|
||||
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):
|
||||
"""Single-page options."""
|
||||
@@ -542,7 +556,7 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
||||
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
||||
icons: dict[str, str] = {}
|
||||
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() == "":
|
||||
app_map = {}
|
||||
else:
|
||||
@@ -551,14 +565,14 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
||||
errors["base"] = "invalid_app_map"
|
||||
if not errors:
|
||||
if delay is not None:
|
||||
self._options[CONF_CHANNEL_DIGIT_DELAY] = delay
|
||||
self._options.update(user_input)
|
||||
self._options[CONF_APP_NAME_MAP] = app_map
|
||||
self._options_dict[CONF_CHANNEL_DIGIT_DELAY] = delay
|
||||
self._options_dict.update(user_input)
|
||||
self._options_dict[CONF_APP_NAME_MAP] = app_map
|
||||
# P5-2: the icon column — the editor is the source of
|
||||
# truth, so a save without icons clears them (and a
|
||||
# single-column map stays valid).
|
||||
self._options[CONF_APP_ICON_MAP] = icons
|
||||
return self.async_create_entry(title="", data=self._options)
|
||||
self._options_dict[CONF_APP_ICON_MAP] = icons
|
||||
return self.async_create_entry(title="", data=self._options_dict)
|
||||
|
||||
sel = _build_selectors()
|
||||
schema_dict = {}
|
||||
@@ -566,46 +580,36 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
||||
# Apps name/icon map editor (P4-1, core androidtv_remote options
|
||||
# pattern, simplified): one package=name[|icon] per line (P5-2 adds
|
||||
# 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
|
||||
|
||||
# Jellyfin
|
||||
jf = self._options.get(CONF_JELLYFIN_ENTITY)
|
||||
jf = self._options_dict.get(CONF_JELLYFIN_ENTITY)
|
||||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||||
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"]
|
||||
|
||||
# Receiver
|
||||
recv = self._options.get(CONF_RECEIVER_ENTITY)
|
||||
recv = self._options_dict.get(CONF_RECEIVER_ENTITY)
|
||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||
if recv:
|
||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv)
|
||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||
schema_dict[recv_key] = sel["receiver"]
|
||||
|
||||
# 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, default=current_delay)] = str
|
||||
current_enter = self._options.get(CONF_CHANNEL_SEND_ENTER, False)
|
||||
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER, default=current_enter)] = bool
|
||||
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
|
||||
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
|
||||
|
||||
# P5-3: the live events push is opt-in (default off; the user
|
||||
# tests stability on their device and reports)
|
||||
current_logcat = self._options.get(CONF_LOGCAT_EVENTS, False)
|
||||
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS, default=current_logcat)] = bool
|
||||
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS)] = bool
|
||||
|
||||
# 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, default=current_flag)] = bool
|
||||
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED)] = bool
|
||||
|
||||
# Receiver input — dropdown from DAG if receiver is set, text otherwise
|
||||
current_input = self._options.get(CONF_RECEIVER_INPUT, "")
|
||||
receiver = self._options.get(CONF_RECEIVER_ENTITY)
|
||||
current_input = self._options_dict.get(CONF_RECEIVER_INPUT, "")
|
||||
receiver = self._options_dict.get(CONF_RECEIVER_ENTITY)
|
||||
inputs = _get_receiver_inputs(self.hass, receiver)
|
||||
|
||||
if inputs:
|
||||
@@ -618,43 +622,32 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
||||
)
|
||||
input_key = vol.Optional(CONF_RECEIVER_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
|
||||
else:
|
||||
schema_dict[vol.Optional(
|
||||
CONF_RECEIVER_INPUT, default=current_input
|
||||
)] = str
|
||||
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
||||
|
||||
# Power
|
||||
pwr = self._options.get(CONF_POWER_ENTITY)
|
||||
pwr = self._options_dict.get(CONF_POWER_ENTITY)
|
||||
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||
if pwr:
|
||||
pwr_key = vol.Optional(CONF_POWER_ENTITY, default=pwr)
|
||||
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||
schema_dict[pwr_key] = sel["power"]
|
||||
|
||||
# ADB options
|
||||
schema_dict[vol.Optional(
|
||||
CONF_ADB_ENABLED,
|
||||
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
|
||||
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
|
||||
|
||||
# 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,
|
||||
default=adb_host,
|
||||
)] = str
|
||||
schema_dict[vol.Optional(
|
||||
CONF_ADB_PORT,
|
||||
default=self._options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||
)] = int
|
||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||
schema_dict[vol.Optional(CONF_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(
|
||||
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,
|
||||
)
|
||||
+26
-1
@@ -163,7 +163,32 @@ def install() -> None:
|
||||
},
|
||||
)
|
||||
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
|
||||
|
||||
exceptions = types.ModuleType("homeassistant.exceptions")
|
||||
|
||||
@@ -76,7 +76,30 @@ def _install_import_stubs() -> None:
|
||||
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
||||
|
||||
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
|
||||
|
||||
core = types.ModuleType("homeassistant.core")
|
||||
|
||||
@@ -132,6 +132,26 @@ def _install_stubs() -> None:
|
||||
|
||||
class OptionsFlow:
|
||||
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
|
||||
ConfigFlow._get_reauth_entry = lambda self, entry=None: entry
|
||||
@@ -148,6 +168,7 @@ def _install_stubs() -> None:
|
||||
|
||||
config_entries.ConfigFlow = ConfigFlow
|
||||
config_entries.OptionsFlow = OptionsFlow
|
||||
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||
sys.modules["homeassistant.config_entries"] = config_entries
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ _STUB_NAMES = (
|
||||
"homeassistant.const",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.entity_registry",
|
||||
"homeassistant.helpers.device_registry",
|
||||
"homeassistant.helpers.selector",
|
||||
"nsh.helpers",
|
||||
"nsh.config_flow",
|
||||
@@ -94,6 +95,25 @@ def _install_stubs() -> None:
|
||||
def async_create_entry(self, *, title, 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
|
||||
# module object (the stub closure reads this same object).
|
||||
_zc_holder = types.SimpleNamespace(entries=[])
|
||||
@@ -104,6 +124,8 @@ def _install_stubs() -> None:
|
||||
|
||||
config_entries.ConfigFlow = ConfigFlow
|
||||
config_entries.OptionsFlow = OptionsFlow
|
||||
OptionsFlowWithReload.async_show_form = OptionsFlow.async_show_form # form plumbing
|
||||
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||
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"] = 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.EntitySelector = lambda *a, **k: None
|
||||
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||
@@ -380,7 +406,8 @@ class TestAppIconColumn:
|
||||
async def test_two_column_line_stores_the_icon(self, tmp_path):
|
||||
module = _form_module()
|
||||
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.async_show_form = lambda **kwargs: {"type": "form", **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):
|
||||
module = _form_module()
|
||||
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.async_show_form = lambda **kwargs: {"type": "form", **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):
|
||||
module = _form_module()
|
||||
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.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||
@@ -441,7 +470,8 @@ class TestChannelEntryOptions:
|
||||
|
||||
module = _form_module()
|
||||
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))
|
||||
flow.hass = hass
|
||||
flow._options = dict(options) if options else {}
|
||||
@@ -481,6 +511,71 @@ class TestChannelEntryOptions:
|
||||
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:
|
||||
"""t29 G3a: the deprecated listener+reload combo is gone (grep pins)."""
|
||||
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):
|
||||
module = _form_module()
|
||||
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))
|
||||
return flow
|
||||
|
||||
|
||||
Reference in new issue
Block a user