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
+229
-71
No files matched your search
@@ -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