diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 4e5cf8d..ea4c78b 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -370,6 +370,19 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="already_configured") self._abort_if_unique_id_configured() + else: + # t32 F2 (review rider): MAC-less discovery cannot carry a + # unique_id — HA's helper records the uniqueness-less discovery + # (the same discovery context can't stack duplicates) and the + # host guard keeps a second discovery of an already-configured + # host from stacking another prefill. + self._async_handle_discovery_without_unique_id() + for entry in self._async_current_entries(): + existing_host = entry.options.get( + CONF_PROTOCOL_HOST + ) or entry.options.get(CONF_ADB_HOST) + if existing_host and existing_host == host: + return self.async_abort(reason="already_configured") # First-time setup: pre-filled user step return await self.async_step_user() @@ -490,7 +503,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # duplicate guards yhost = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST) - for existing in self.hass.config_entries.async_entries(): + for existing in self._async_current_entries(): ehost = existing.options.get(CONF_PROTOCOL_HOST) or existing.options.get( CONF_ADB_HOST ) diff --git a/tests/entity_stublib.py b/tests/entity_stublib.py index 2cee30d..19d6c73 100644 --- a/tests/entity_stublib.py +++ b/tests/entity_stublib.py @@ -144,6 +144,10 @@ def install() -> None: async_set_unique_id = _stub_set_unique_id _abort_if_unique_id_configured = _noop_abort + def _async_handle_discovery_without_unique_id(self): + # t32 F2 (audited helper): recording no-op at hub scope + pass + def async_show_form(self, *, step_id, data_schema=None, errors=None): return { "type": "form", diff --git a/tests/test_init.py b/tests/test_init.py index c0386ea..8aed762 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -223,6 +223,9 @@ class TestImportStep: **kwargs, } flow.async_abort = lambda **kwargs: {"type": "abort", **kwargs} + flow._async_current_entries = lambda: list( + hass.config_entries.async_entries() + ) return flow async def test_ui_configured_host_never_re_seeded(self): diff --git a/tests/test_standalone_setup.py b/tests/test_standalone_setup.py index 8bda98e..1d70176 100644 --- a/tests/test_standalone_setup.py +++ b/tests/test_standalone_setup.py @@ -95,6 +95,12 @@ def _install_stubs() -> None: def _async_current_entries(self): return _zc_holder.entries + # t32 F2: the audited helper for uniqueness-less discovery — a + # recording no-op here; the flow's own host guard does the stacking + # prevention the acceptance pins. + def _async_handle_discovery_without_unique_id(self): + _zc_holder.discovery_without_unique_id_calls += 1 + class OptionsFlow: def async_show_form(self, *, step_id, data_schema=None, errors=None): return { @@ -128,7 +134,7 @@ def _install_stubs() -> None: # 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=[]) + _zc_holder = types.SimpleNamespace(entries=[], discovery_without_unique_id_calls=0) sys.modules[__name__]._zc_holder = _zc_holder class AbortIfConfiguredError(Exception): @@ -835,6 +841,50 @@ class TestIpChangeSelfHealF1: assert factory() is None +class TestMacLessDiscoveryF2: + """t32 F2 (rider): MAC-less zeroconf discovery records the + uniqueness-less discovery and can't stack a duplicate prefill.""" + + def _discovery(self, host="192.168.1.50", props=None): + return SimpleNamespace( + ip_address=host, host=host, properties=props or {} + ) + + async def test_mac_less_records_the_helper(self, tmp_path): + module = _form_module() + flow = module.NvidiaShieldConfigFlow() + flow.hass = FakeHass(str(tmp_path)) + sys.modules[__name__]._zc_holder.discovery_without_unique_id_calls = 0 + + result = await flow.async_step_zeroconf(self._discovery()) + + assert result["type"] == "form" # prefill still offered once + assert ( + sys.modules[__name__]._zc_holder.discovery_without_unique_id_calls + == 1 + ) + + async def test_mac_less_rediscovery_does_not_stack(self, tmp_path): + """A second MAC-less discovery of an already-configured host + aborts — one-entry outcome.""" + module = _form_module() + flow = module.NvidiaShieldConfigFlow() + flow.hass = FakeHass(str(tmp_path)) + entry = SimpleNamespace( + entry_id="e1", + unique_id=None, # MAC-less entry + options={CONF_PROTOCOL_HOST: "192.168.1.50"}, + title="Shield", + ) + sys.modules[__name__]._zc_holder.entries[:] = [entry] + + result = await flow.async_step_zeroconf(self._discovery()) + + assert result == {"type": "abort", "reason": "already_configured"} + # the entries list is untouched: one-entry outcome + assert len(sys.modules[__name__]._zc_holder.entries) == 1 + + class TestZeroconfDiscovery: """P4-1a: _androidtvremote2._tcp discovery → pre-filled user step."""