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
+229 -71

No files matched your search

+26 -1
View File
@@ -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")
+24 -1
View File
@@ -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")
+21
View File
@@ -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
+101 -5
View File
@@ -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