diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 94f0e60..6e80551 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,18 +35,10 @@ jobs: strategy: fail-fast: false matrix: - # 3.13 ONLY, for now. 3.11 is the declared floor (`pyproject.toml` - # requires-python, and pylint's own py-version) and SHOULD be here, but - # four tests in tests/test_bridge_client.py fail on 3.11 today: they - # assert on `client.status` straight after `wait_connected()` and pass - # on 3.13 purely because its event loop happens to have run the attach - # by then. Scheduling order, not logic — and pre-existing, confirmed - # failing at 913ad44, well before the refactor that added this file. - # - # Adding a red leg to a brand-new gate only teaches people to ignore - # the gate. Tracked separately; restore "3.11" here once those four - # tests await the condition instead of assuming it. - python-version: ["3.13"] + # 3.11 is the declared floor (`pyproject.toml` requires-python, and + # pylint's own py-version); 3.13 is what jarvis actually runs. Both + # legs are cheap (~1s each), so both run on every push. + python-version: ["3.11", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/indigo-matter.indigoPlugin/Contents/Info.plist b/indigo-matter.indigoPlugin/Contents/Info.plist index 5ae041b..26abdc9 100644 --- a/indigo-matter.indigoPlugin/Contents/Info.plist +++ b/indigo-matter.indigoPlugin/Contents/Info.plist @@ -20,7 +20,7 @@ IwsApiVersion 1.0.0 PluginVersion - 2026.28.7 + 2026.28.8 ServerApiVersion 3.6 diff --git a/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py b/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py index 6e5a782..fd14099 100644 --- a/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py +++ b/indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py @@ -230,6 +230,7 @@ def __init__( #: The last StatusReport the node returned (attach / get_status). self.status: Optional[StatusReport] = None self._attached = False + self._attached_event = asyncio.Event() #: True while the node refused the attach with ``endpoint_map_invalid`` #: and we are holding the connection open for the §1.1 recovery trio. self.recovery = False @@ -245,8 +246,27 @@ def attached(self) -> bool: """ return self._attached and self.connected + async def wait_attached(self, timeout: float = 30.0) -> None: + """Block until an ``attach`` the node accepted has landed. + + :meth:`wait_connected` only proves the transport is up — the socket + exists the moment the handshake starts, but ``self.status`` and + ``self._attached`` are not populated until the attach that follows + it completes, on the run loop, some time later. A caller that reads + ``status`` or relies on ``set_state`` being deliverable right after + ``wait_connected`` returns is trusting that the node is already + serving its endpoint set — which is exactly the assumption that is + NOT guaranteed: the attach might still be in flight, or might have + been refused (§1.1's ``endpoint_map_invalid``), leaving a live socket + that is connected but not attached. Wait on this instead of + ``wait_connected`` whenever what you need is the node actually + serving your endpoints, not merely a live socket to it. + """ + await asyncio.wait_for(self._attached_event.wait(), timeout) + def _mark_disconnected(self) -> None: self._attached = False + self._attached_event.clear() self.recovery = False super()._mark_disconnected() @@ -293,6 +313,7 @@ async def _handshake(self, first: Any) -> None: self._handle_attach_refused(exc) return self._attached = True + self._attached_event.set() self.recovery = False self._notify(self._on_attached, status, replace_all) @@ -437,6 +458,7 @@ async def _retry_with_intent(self) -> bool: "rather than halting", owed) status = await self._attach(None, replace_all=True, timeout=None, inline=True) self._attached = True + self._attached_event.set() self.recovery = False self._notify(self._on_attached, status, True) return True @@ -596,6 +618,7 @@ async def _attach(self, endpoints: Optional[list], *, replace_all: bool, else: result = await self._request_frame(frame, timeout) self._attached = True + self._attached_event.set() self.recovery = False self.status = bridge_protocol.parse_status(result) return self.status diff --git a/tests/indigo_fakes.py b/tests/indigo_fakes.py new file mode 100644 index 0000000..971a0f9 --- /dev/null +++ b/tests/indigo_fakes.py @@ -0,0 +1,322 @@ +"""Stateful fake Indigo devices/device-command-namespace for INBOUND tests. + +Used by test_device_sync.py, test_generic_switch.py, test_power_source.py, +and test_integration.py to stand in for ``indigo.devices``/``indigo.device`` +when exercising device_sync's reconciliation and event-routing paths without +a live Indigo server. + +These fakes are deliberately STATEFUL — a ``states`` dict that mutates on +``updateStatesOnServer``, ``stateListOrDisplayStateIdChanged`` call counting, +``replaceOnServer`` failure/rollback semantics, and a real device-group model +(see ``FakeDeviceFactory``, per ADR-0009) — because the inbound code under +test reads state back and branches on it (e.g. "was the rebuild hook called +exactly once?", "did a failed replace roll the name back?"). + +Do NOT merge these with fakes.py's ``FakeIndigoDevice``/``FakeIndigoDevices``. +Those are EXPORT-side static attribute holders for a different direction of +data flow and are deliberately simpler; conflating the two families would +either weaken this stateful suite or drag unwanted state-machine behaviour +into the export-side one. Keep them in separate modules. +""" +from __future__ import annotations + +# Real Indigo auto-derives these built-in states from Supports* props, both at +# device CREATION and at any later pluginProps replace. FakeDev seeds them in +# both places so handler update guards (e.g. ElectricalPowerHandler's +# `"curEnergyLevel" not in indigo_dev.states`) behave like the real server — +# issue #79's priming/live-routing tests need this true immediately after +# creation, not only after a reconcile-triggered replacePluginPropsOnServer. +_SUPPORTS_TO_STATE = { + "SupportsPowerMeter": "curEnergyLevel", + "SupportsEnergyMeter": "accumEnergyTotal", + "SupportsBatteryLevel": "batteryLevel", + "SupportsSensorValue": "sensorValue", +} + +# Devices.xml declares these as plain custom states (no Supports* prop gates +# them) — real Indigo instantiates a device's declared at CREATION +# regardless of props, unlike the Supports*-driven built-ins above. Only +# BooleanStateConfigHandler's guard (issue #85 — "sensitivityLevel" not in +# indigo_dev.states) needs this modelled; matterLock's lockState needs no +# guard (door_lock.py writes it unconditionally) so it needs no seeding here. +_STATIC_DEVICE_TYPE_STATES = { + # Mirrors the each type declares in Devices.xml — real Indigo + # creates a device with every declared state present, and the handlers' + # "is this state on this device?" guards depend on that being true. + "matterMotionSensor": {"sensitivityLevel", "holdTime"}, + "matterContactSensor": {"sensitivityLevel"}, + "matterRelay": {"startUpOnOff"}, + # issue #204 / ADR-0008 — declared unconditionally in Devices.xml (no + # Supports* prop gates any of them), same custom-type discipline as + # matterEnergyMeter/matterUnknown's `reachable`. curEnergyLevel/ + # accumEnergyTotal joined the list once ep-0 energy attribution shipped + # (issue #204's final stage) — same ids matterEnergyMeter already + # declares, also unconditionally. + "matterNode": {"nodeLabel", "softwareVersion", "batteryLevel", "reachable", + "curEnergyLevel", "accumEnergyTotal"}, +} + + +class FakeDev: + def __init__(self, dev_id, name, device_type_id, props, initial_states=None): + self.id = dev_id + self.name = name + self.deviceTypeId = device_type_id + self.pluginProps = props + self.states = {} + for prop_key, state_key in _SUPPORTS_TO_STATE.items(): + if props.get(prop_key): + self.states[state_key] = 0 # Indigo-style initial value + for state_key in _STATIC_DEVICE_TYPE_STATES.get(device_type_id, ()): + self.states.setdefault(state_key, 0) + # Callers that need states Devices.xml wouldn't auto-derive here (e.g. + # matterButton's lastButtonEvent/pressCount) seed them explicitly. + self.states.update(dict(initial_states or {})) + self.error = None + self.errorState = "" + self.folderId = 0 + # See replaceOnServer: fail_replace models real Indigo's discard-on- + # failure semantics; _name_on_server is the last name Indigo accepted. + self.fail_replace = False + self._name_on_server = name + # Issue #190: how many times Indigo was asked to rebuild this device's + # state list. The question the tests care about is WHEN that is asked + # for, not what Indigo does next, so the fake only counts. + self.state_list_rebuilds = 0 + # Real Indigo derives the list display from Supports* props at CREATION + # and caches it (issue #56) — approximate the precedence rule verified + # live on jarvis: a True Supports* wins; with BOTH explicitly False the + # Devices.xml UiDisplayStateId applies ("uiDisplayState" stands in for + # it here). Deliberately do NOT re-derive in replacePluginPropsOnServer + # below (models the pessimistic cached case the warn path exists for). + if props.get("SupportsSensorValue"): + self.displayStateId = "sensorValue" + elif "SupportsOnState" in props and not props.get("SupportsOnState"): + self.displayStateId = "uiDisplayState" + else: + self.displayStateId = "onOffState" + + def updateStatesOnServer(self, kvlist): + for kv in kvlist: + self.states[kv["key"]] = kv["value"] + # Real Indigo's states dict answers a "key.ui" lookup with the + # display value from a uiValue-bearing write (used by both fix A's + # evidence check and fix D's reachable/unreachable column text). + if "uiValue" in kv: + self.states[f"{kv['key']}.ui"] = kv["uiValue"] + + def stateListOrDisplayStateIdChanged(self): + self.state_list_rebuilds += 1 + + def setErrorStateOnServer(self, value): + self.error = value + self.errorState = value + + def replaceOnServer(self): + # Real Indigo persists the in-memory edits (name etc.). Production + # writes dev.name BEFORE calling this, and real Indigo discards the + # in-memory edit on failure — so a failing fake must roll the name + # back, or later passes vote on a name Indigo never had (issue #204 + # verification round). Set fail_replace=True to model failure; do NOT + # override this method with a bare raiser. + if self.fail_replace: + self.name = self._name_on_server + raise ValueError("replaceOnServer refused (fail_replace)") + self.replaced = True + self._name_on_server = self.name + + def replacePluginPropsOnServer(self, new_props): + # Real Indigo updates pluginProps and rebuilds device states from Supports* + # entries. The fake merges the new props and, for each Supports* key that + # transitions to True, seeds the corresponding state so handler guards pass. + self.pluginProps = dict(new_props) + self.replaced_props = True + # Simulate Indigo auto-creating states for Supports* props. + for prop_key, state_key in _SUPPORTS_TO_STATE.items(): + if new_props.get(prop_key) and state_key not in self.states: + self.states[state_key] = 0 # Indigo-style initial value + + +class FakeFolder: + def __init__(self, folder_id, name): + self.id = folder_id + self.name = name + + +class FakeFolderFactory: + """Stands in for ``indigo.devices.folder`` (the folder command namespace).""" + + def __init__(self, devices): + self.devices = devices + + def create(self, name): + return self.devices.add_folder(name) + + +class FakeDevices: + def __init__(self): + self._by_id = {} + self._counter = 1000 + self._folders = {} + self._folder_counter = 0 + + def next_id(self): + self._counter += 1 + return self._counter + + def add(self, dev): + self._by_id[dev.id] = dev + + def add_folder(self, name): + self._folder_counter += 1 + folder = FakeFolder(self._folder_counter, name) + self._folders[folder.id] = folder + return folder + + @property + def folders(self): + return list(self._folders.values()) + + def __iter__(self): + return iter(list(self._by_id.values())) + + def __getitem__(self, dev_id): + return self._by_id[dev_id] + + def iter(self, _filter=None): + # Real Indigo EXCLUDES unconfigured devices from iter("self") — that + # exclusion is the whole mechanism of issue #62, so the fake has to + # model it or a test for the stray warning would pass vacuously. + # Plain iteration (__iter__) stays unfiltered, like the real + # `indigo.devices`, which is the only place a stray is still visible. + return [dev for dev in self._by_id.values() if getattr(dev, "configured", True)] + + +class FakeDeviceFactory: + """Stands in for the ``indigo.device`` command namespace. + + Since issue #204 stage 2 this carries a REAL device-group model rather than + recording calls, and since ADR-0009 that model is the one the CONTROLLED + EXPERIMENT on jarvis (2026-08-12) established rather than the one the docs + imply: + + * Indigo orders a group's members by device AGE (creation order), and the + OLDEST member is the root — ``getGroupList``'s first element, identical + whichever member is asked. + * ``groupWithDevice(a, b)`` and ``groupWithDevice(b, a)`` produce the + SAME group. **The argument order does nothing.** A fake that honoured + arg order would let a plugin that (wrongly) depends on it pass. + * ``indigo.device.delete`` REFUSES to delete the root of a non-empty + group — whichever device that turns out to be, which is what makes + ``delete_node``'s dissolve-first shape load-bearing rather than + decorative. + """ + + def __init__(self, devices): + self.devices = devices + self.created = [] + #: dev_id → the group's member list, SHARED by every member, ordered + #: OLDEST FIRST; [0] is therefore the root. Absent means ungrouped. + self.groups = {} + #: (dev_1, dev_2) per groupWithDevice call — the idempotence assertion + #: is "a second reconcile pass adds none of these". The order inside + #: the tuple is what the plugin passed and means nothing to Indigo. + self.group_calls = [] + self.ungroup_calls = [] + + @staticmethod + def _id_of(dev_or_id): + return dev_or_id.id if hasattr(dev_or_id, "id") else int(dev_or_id) + + def _age_of(self, dev_id): + """Creation sequence of a device — lower is older. + + ``FakeDevices.next_id`` is a monotonic counter, so an id IS its + creation rank for every device these tests make; devices built by hand + with an explicit id (the orphan/ghost fixtures) sort by that id, which + is all the ordering they need. + """ + return dev_id + + def create(self, protocol=None, deviceTypeId="", name="", props=None, folder=0, **kwargs): + dev = FakeDev(self.devices.next_id(), name, deviceTypeId, dict(props or {})) + if isinstance(folder, int) and folder: + dev.folderId = folder + self.devices.add(dev) + self.created.append(dev) + return dev + + def delete(self, dev): + dev_id = self._id_of(dev) + members = self.groups.get(dev_id) + if members and len(members) > 1 and members[0] == dev_id: + raise ValueError( + "cannot delete device %s: it is the root of a non-empty device group" % dev_id) + self._drop_from_group(dev_id) + self.devices._by_id.pop(dev_id, None) + + def moveToFolder(self, dev_or_id, value=None): + dev = dev_or_id if hasattr(dev_or_id, "folderId") else self.devices[dev_or_id] + dev.folderId = value + + # -- the device-group model ----------------------------------------- + def getGroupList(self, dev_or_id): + dev_id = self._id_of(dev_or_id) + if dev_id not in self.devices._by_id: + # Real Indigo cannot answer for an id that is not a device, and the + # plugin's index CAN carry one: plugin.deviceDeleted only prunes + # matterNode ids, so a hand-deleted endpoint device leaves a dead + # id behind (issue #204 review, fix D). Without this tooth the + # grouping sweep looks harmless against a fake that answers anyway. + raise ValueError("device %s does not exist" % dev_id) + members = self.groups.get(dev_id) + # An ungrouped device answers with just itself — the plugin tolerates an + # empty list too (both shapes are undocumented; see _ensure_grouped). + return list(members) if members else [dev_id] + + def groupWithDevice(self, dev_1, dev_2): + """The experiment's semantics: the two devices' groups are UNIONED and + the result is ordered by device age, oldest first. + + ``groupWithDevice(motion, node)`` and ``groupWithDevice(node, motion)`` + returned byte-identical member lists on the live rig, and adding a + third device to an existing pair left the root untouched — so this + models a symmetric union with an age sort and no notion of a joiner. + + What is and isn't experiment-backed, honestly: SINGLETON+SINGLETON + (both argument orders) and SINGLETON-JOINS-EXISTING-GROUP (the + experiment's third line) are what the live rig actually exercised. + A general GROUP+GROUP union — two already-multi-member groups + merged in one call — is EXTRAPOLATED from those, never run on + jarvis. Production's own guard (`_ensure_grouped`'s family check + only ever passes a SINGLETON node device as one side) means only + the backed directions are exercised today; this fake unions + unconditionally because nothing here currently calls it any other + way. Anyone relaxing that guard to call this with two genuine + multi-member groups must extend the live experiment first, not + just trust this model to still be right. + """ + first, second = self._id_of(dev_1), self._id_of(dev_2) + self.group_calls.append((first, second)) + members = set(self.groups.get(first) or [first]) + members |= set(self.groups.get(second) or [second]) + ordered = sorted(members, key=self._age_of) + for member in ordered: + self.groups[member] = ordered + + def ungroupDevice(self, dev_or_id): + dev_id = self._id_of(dev_or_id) + self.ungroup_calls.append(dev_id) + self._drop_from_group(dev_id) + + def _drop_from_group(self, dev_id): + members = self.groups.pop(dev_id, None) + if not members: + return + remaining = [member for member in members if member != dev_id] + for member in remaining: + # A group of one is no group at all. + if len(remaining) > 1: + self.groups[member] = remaining + else: + self.groups.pop(member, None) diff --git a/tests/test_bridge_client.py b/tests/test_bridge_client.py index 51dde30..c6c5f67 100644 --- a/tests/test_bridge_client.py +++ b/tests/test_bridge_client.py @@ -429,7 +429,7 @@ async def scenario(): {bridge_protocol.CMD_SET_STATE: EXCHANGES["set_state_unknown_device"]["response"]})) client = _client(mock_logger, fake) task = asyncio.create_task(client.run()) - await client.wait_connected(timeout=2) + await client.wait_attached(timeout=2) await client.set_state(123456791, {"onOff": True}) await settle(lambda: mock_logger.warning.called) @@ -978,7 +978,7 @@ async def scenario(): fake = DyingSocket(responder=golden_responder(), handshake=HELLO) client = _client(mock_logger, fake) task = asyncio.create_task(client.run()) - await client.wait_connected(timeout=2) + await client.wait_attached(timeout=2) mock_logger.debug.reset_mock() await client.set_state(123456789, {"onOff": True}) # must not raise @@ -1015,7 +1015,7 @@ async def scenario(): {bridge_protocol.CMD_SET_STATE: EXCHANGES["set_state_unknown_device"]["response"]})) client = _client(mock_logger, fake) task = asyncio.create_task(client.run()) - await client.wait_connected(timeout=2) + await client.wait_attached(timeout=2) await client.set_state(123456791, {"onOff": True}) await settle(lambda: mock_logger.warning.called) @@ -1119,7 +1119,7 @@ def responder(frame): fake = _fake(responder=responder) client = _client(mock_logger, fake) task = asyncio.create_task(client.run()) - await client.wait_connected(timeout=2) + await client.wait_attached(timeout=2) assert client.status.endpoint_count == 0 # The node now serves the two-endpoint set. diff --git a/tests/test_device_sync.py b/tests/test_device_sync.py index 2de1fd1..71e253d 100644 --- a/tests/test_device_sync.py +++ b/tests/test_device_sync.py @@ -16,304 +16,12 @@ from protocol import MatterEvent from test_handlers import RELAY_NODE - -# Real Indigo auto-derives these built-in states from Supports* props, both at -# device CREATION and at any later pluginProps replace. FakeDev seeds them in -# both places so handler update guards (e.g. ElectricalPowerHandler's -# `"curEnergyLevel" not in indigo_dev.states`) behave like the real server — -# issue #79's priming/live-routing tests need this true immediately after -# creation, not only after a reconcile-triggered replacePluginPropsOnServer. -_SUPPORTS_TO_STATE = { - "SupportsPowerMeter": "curEnergyLevel", - "SupportsEnergyMeter": "accumEnergyTotal", - "SupportsBatteryLevel": "batteryLevel", - "SupportsSensorValue": "sensorValue", -} - -# Devices.xml declares these as plain custom states (no Supports* prop gates -# them) — real Indigo instantiates a device's declared at CREATION -# regardless of props, unlike the Supports*-driven built-ins above. Only -# BooleanStateConfigHandler's guard (issue #85 — "sensitivityLevel" not in -# indigo_dev.states) needs this modelled; matterLock's lockState needs no -# guard (door_lock.py writes it unconditionally) so it needs no seeding here. -_STATIC_DEVICE_TYPE_STATES = { - # Mirrors the each type declares in Devices.xml — real Indigo - # creates a device with every declared state present, and the handlers' - # "is this state on this device?" guards depend on that being true. - "matterMotionSensor": {"sensitivityLevel", "holdTime"}, - "matterContactSensor": {"sensitivityLevel"}, - "matterRelay": {"startUpOnOff"}, - # issue #204 / ADR-0008 — declared unconditionally in Devices.xml (no - # Supports* prop gates any of them), same custom-type discipline as - # matterEnergyMeter/matterUnknown's `reachable`. curEnergyLevel/ - # accumEnergyTotal joined the list once ep-0 energy attribution shipped - # (issue #204's final stage) — same ids matterEnergyMeter already - # declares, also unconditionally. - "matterNode": {"nodeLabel", "softwareVersion", "batteryLevel", "reachable", - "curEnergyLevel", "accumEnergyTotal"}, -} - - -class FakeDev: - def __init__(self, dev_id, name, device_type_id, props): - self.id = dev_id - self.name = name - self.deviceTypeId = device_type_id - self.pluginProps = props - self.states = {} - for prop_key, state_key in _SUPPORTS_TO_STATE.items(): - if props.get(prop_key): - self.states[state_key] = 0 # Indigo-style initial value - for state_key in _STATIC_DEVICE_TYPE_STATES.get(device_type_id, ()): - self.states.setdefault(state_key, 0) - self.error = None - self.errorState = "" - self.folderId = 0 - # See replaceOnServer: fail_replace models real Indigo's discard-on- - # failure semantics; _name_on_server is the last name Indigo accepted. - self.fail_replace = False - self._name_on_server = name - # Issue #190: how many times Indigo was asked to rebuild this device's - # state list. The question the tests care about is WHEN that is asked - # for, not what Indigo does next, so the fake only counts. - self.state_list_rebuilds = 0 - # Real Indigo derives the list display from Supports* props at CREATION - # and caches it (issue #56) — approximate the precedence rule verified - # live on jarvis: a True Supports* wins; with BOTH explicitly False the - # Devices.xml UiDisplayStateId applies ("uiDisplayState" stands in for - # it here). Deliberately do NOT re-derive in replacePluginPropsOnServer - # below (models the pessimistic cached case the warn path exists for). - if props.get("SupportsSensorValue"): - self.displayStateId = "sensorValue" - elif "SupportsOnState" in props and not props.get("SupportsOnState"): - self.displayStateId = "uiDisplayState" - else: - self.displayStateId = "onOffState" - - def updateStatesOnServer(self, kvlist): - for kv in kvlist: - self.states[kv["key"]] = kv["value"] - # Real Indigo's states dict answers a "key.ui" lookup with the - # display value from a uiValue-bearing write (used by both fix A's - # evidence check and fix D's reachable/unreachable column text). - if "uiValue" in kv: - self.states[f"{kv['key']}.ui"] = kv["uiValue"] - - def stateListOrDisplayStateIdChanged(self): - self.state_list_rebuilds += 1 - - def setErrorStateOnServer(self, value): - self.error = value - self.errorState = value - - def replaceOnServer(self): - # Real Indigo persists the in-memory edits (name etc.). Production - # writes dev.name BEFORE calling this, and real Indigo discards the - # in-memory edit on failure — so a failing fake must roll the name - # back, or later passes vote on a name Indigo never had (issue #204 - # verification round). Set fail_replace=True to model failure; do NOT - # override this method with a bare raiser. - if self.fail_replace: - self.name = self._name_on_server - raise ValueError("replaceOnServer refused (fail_replace)") - self.replaced = True - self._name_on_server = self.name - - def replacePluginPropsOnServer(self, new_props): - # Real Indigo updates pluginProps and rebuilds device states from Supports* - # entries. The fake merges the new props and, for each Supports* key that - # transitions to True, seeds the corresponding state so handler guards pass. - self.pluginProps = dict(new_props) - self.replaced_props = True - # Simulate Indigo auto-creating states for Supports* props. - for prop_key, state_key in _SUPPORTS_TO_STATE.items(): - if new_props.get(prop_key) and state_key not in self.states: - self.states[state_key] = 0 # Indigo-style initial value - - -class FakeFolder: - def __init__(self, folder_id, name): - self.id = folder_id - self.name = name - - -class FakeFolderFactory: - """Stands in for ``indigo.devices.folder`` (the folder command namespace).""" - - def __init__(self, devices): - self.devices = devices - - def create(self, name): - return self.devices.add_folder(name) - - -class FakeDevices: - def __init__(self): - self._by_id = {} - self._counter = 1000 - self._folders = {} - self._folder_counter = 0 - - def next_id(self): - self._counter += 1 - return self._counter - - def add(self, dev): - self._by_id[dev.id] = dev - - def add_folder(self, name): - self._folder_counter += 1 - folder = FakeFolder(self._folder_counter, name) - self._folders[folder.id] = folder - return folder - - @property - def folders(self): - return list(self._folders.values()) - - def __iter__(self): - return iter(list(self._by_id.values())) - - def __getitem__(self, dev_id): - return self._by_id[dev_id] - - def iter(self, _filter=None): - # Real Indigo EXCLUDES unconfigured devices from iter("self") — that - # exclusion is the whole mechanism of issue #62, so the fake has to - # model it or a test for the stray warning would pass vacuously. - # Plain iteration (__iter__) stays unfiltered, like the real - # `indigo.devices`, which is the only place a stray is still visible. - return [dev for dev in self._by_id.values() if getattr(dev, "configured", True)] - - -class FakeDeviceFactory: - """Stands in for the ``indigo.device`` command namespace. - - Since issue #204 stage 2 this carries a REAL device-group model rather than - recording calls, and since ADR-0009 that model is the one the CONTROLLED - EXPERIMENT on jarvis (2026-08-12) established rather than the one the docs - imply: - - * Indigo orders a group's members by device AGE (creation order), and the - OLDEST member is the root — ``getGroupList``'s first element, identical - whichever member is asked. - * ``groupWithDevice(a, b)`` and ``groupWithDevice(b, a)`` produce the - SAME group. **The argument order does nothing.** A fake that honoured - arg order would let a plugin that (wrongly) depends on it pass. - * ``indigo.device.delete`` REFUSES to delete the root of a non-empty - group — whichever device that turns out to be, which is what makes - ``delete_node``'s dissolve-first shape load-bearing rather than - decorative. - """ - - def __init__(self, devices): - self.devices = devices - self.created = [] - #: dev_id → the group's member list, SHARED by every member, ordered - #: OLDEST FIRST; [0] is therefore the root. Absent means ungrouped. - self.groups = {} - #: (dev_1, dev_2) per groupWithDevice call — the idempotence assertion - #: is "a second reconcile pass adds none of these". The order inside - #: the tuple is what the plugin passed and means nothing to Indigo. - self.group_calls = [] - self.ungroup_calls = [] - - @staticmethod - def _id_of(dev_or_id): - return dev_or_id.id if hasattr(dev_or_id, "id") else int(dev_or_id) - - def _age_of(self, dev_id): - """Creation sequence of a device — lower is older. - - ``FakeDevices.next_id`` is a monotonic counter, so an id IS its - creation rank for every device these tests make; devices built by hand - with an explicit id (the orphan/ghost fixtures) sort by that id, which - is all the ordering they need. - """ - return dev_id - - def create(self, protocol=None, deviceTypeId="", name="", props=None, folder=0, **kwargs): - dev = FakeDev(self.devices.next_id(), name, deviceTypeId, dict(props or {})) - if isinstance(folder, int) and folder: - dev.folderId = folder - self.devices.add(dev) - self.created.append(dev) - return dev - - def delete(self, dev): - dev_id = self._id_of(dev) - members = self.groups.get(dev_id) - if members and len(members) > 1 and members[0] == dev_id: - raise ValueError( - "cannot delete device %s: it is the root of a non-empty device group" % dev_id) - self._drop_from_group(dev_id) - self.devices._by_id.pop(dev_id, None) - - def moveToFolder(self, dev_or_id, value=None): - dev = dev_or_id if hasattr(dev_or_id, "folderId") else self.devices[dev_or_id] - dev.folderId = value - - # -- the device-group model ----------------------------------------- - def getGroupList(self, dev_or_id): - dev_id = self._id_of(dev_or_id) - if dev_id not in self.devices._by_id: - # Real Indigo cannot answer for an id that is not a device, and the - # plugin's index CAN carry one: plugin.deviceDeleted only prunes - # matterNode ids, so a hand-deleted endpoint device leaves a dead - # id behind (issue #204 review, fix D). Without this tooth the - # grouping sweep looks harmless against a fake that answers anyway. - raise ValueError("device %s does not exist" % dev_id) - members = self.groups.get(dev_id) - # An ungrouped device answers with just itself — the plugin tolerates an - # empty list too (both shapes are undocumented; see _ensure_grouped). - return list(members) if members else [dev_id] - - def groupWithDevice(self, dev_1, dev_2): - """The experiment's semantics: the two devices' groups are UNIONED and - the result is ordered by device age, oldest first. - - ``groupWithDevice(motion, node)`` and ``groupWithDevice(node, motion)`` - returned byte-identical member lists on the live rig, and adding a - third device to an existing pair left the root untouched — so this - models a symmetric union with an age sort and no notion of a joiner. - - What is and isn't experiment-backed, honestly: SINGLETON+SINGLETON - (both argument orders) and SINGLETON-JOINS-EXISTING-GROUP (the - experiment's third line) are what the live rig actually exercised. - A general GROUP+GROUP union — two already-multi-member groups - merged in one call — is EXTRAPOLATED from those, never run on - jarvis. Production's own guard (`_ensure_grouped`'s family check - only ever passes a SINGLETON node device as one side) means only - the backed directions are exercised today; this fake unions - unconditionally because nothing here currently calls it any other - way. Anyone relaxing that guard to call this with two genuine - multi-member groups must extend the live experiment first, not - just trust this model to still be right. - """ - first, second = self._id_of(dev_1), self._id_of(dev_2) - self.group_calls.append((first, second)) - members = set(self.groups.get(first) or [first]) - members |= set(self.groups.get(second) or [second]) - ordered = sorted(members, key=self._age_of) - for member in ordered: - self.groups[member] = ordered - - def ungroupDevice(self, dev_or_id): - dev_id = self._id_of(dev_or_id) - self.ungroup_calls.append(dev_id) - self._drop_from_group(dev_id) - - def _drop_from_group(self, dev_id): - members = self.groups.pop(dev_id, None) - if not members: - return - remaining = [member for member in members if member != dev_id] - for member in remaining: - # A group of one is no group at all. - if len(remaining) > 1: - self.groups[member] = remaining - else: - self.groups.pop(member, None) +from indigo_fakes import ( + FakeDev, + FakeDeviceFactory, + FakeDevices, + FakeFolderFactory, +) @pytest.fixture diff --git a/tests/test_generic_switch.py b/tests/test_generic_switch.py index c6ed5cb..0e7f5d0 100644 --- a/tests/test_generic_switch.py +++ b/tests/test_generic_switch.py @@ -39,6 +39,7 @@ switch_features, ) from matter_handlers.registry import HandlerRegistry +from indigo_fakes import FakeDev, FakeDeviceFactory, FakeDevices, FakeFolderFactory # --------------------------------------------------------------------------- @@ -164,85 +165,6 @@ def test_evt_node_event_constant_value(): assert protocol.EVT_NODE_EVENT == "node_event" -# --------------------------------------------------------------------------- -# Helpers shared with DeviceSync tests -# --------------------------------------------------------------------------- - -class FakeDev: - def __init__(self, dev_id, name, device_type_id, props, initial_states=None): - self.id = dev_id - self.name = name - self.deviceTypeId = device_type_id - self.pluginProps = props - self.states = dict(initial_states or {}) - self.error = None - self.errorState = "" - self.folderId = 0 - - def updateStatesOnServer(self, kvlist): - for kv in kvlist: - self.states[kv["key"]] = kv["value"] - - def setErrorStateOnServer(self, value): - self.error = value - self.errorState = value - - def replaceOnServer(self): - self.replaced = True - - -class FakeDevices: - def __init__(self): - self._by_id = {} - self._counter = 2000 - self._folders = {} - - def next_id(self): - self._counter += 1 - return self._counter - - def add(self, dev): - self._by_id[dev.id] = dev - - @property - def folders(self): - return list(self._folders.values()) - - def __iter__(self): - return iter(list(self._by_id.values())) - - def __getitem__(self, dev_id): - return self._by_id[dev_id] - - def iter(self, _filter=None): - return list(self._by_id.values()) - - -class FakeDeviceFactory: - def __init__(self, devices): - self.devices = devices - self.created = [] - - def create(self, protocol=None, deviceTypeId="", name="", props=None, folder=0, **kwargs): - from test_generic_switch import FakeDev # local import to avoid circular ref - dev = FakeDev(self.devices.next_id(), name, deviceTypeId, dict(props or {})) - self.devices.add(dev) - self.created.append(dev) - return dev - - def moveToFolder(self, dev_or_id, value=None): - pass - - -class FakeFolderFactory: - def __init__(self, devices): - self.devices = devices - - def create(self, name): - from test_generic_switch import FakeDev # noqa - raise RuntimeError("no folder creation in generic switch tests") - - @pytest.fixture def indigo_env(mock_indigo_base): indigo = mock_indigo_base diff --git a/tests/test_integration.py b/tests/test_integration.py index d96f61e..25b66b8 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -20,7 +20,7 @@ from fakes import FakeWebSocket, returns, scripted_responder from test_handlers import RELAY_NODE -from test_device_sync import FakeDev, FakeDeviceFactory, FakeDevices +from indigo_fakes import FakeDev, FakeDeviceFactory, FakeDevices @pytest.fixture diff --git a/tests/test_power_source.py b/tests/test_power_source.py index fc99d86..278c49a 100644 --- a/tests/test_power_source.py +++ b/tests/test_power_source.py @@ -229,13 +229,11 @@ def test_no_power_sources_covers_nothing(): # --------------------------------------------------------------------------- # device_sync integration tests -# (reuse helpers from test_device_sync.py via conftest + local FakeDev copies) +# (reuse the shared Indigo fakes from indigo_fakes.py) # --------------------------------------------------------------------------- -# Import fakes from test_device_sync (they're not importable as a module but we -# can replicate the minimal subset we need here, or import the symbols directly). # Since pytest adds tests/ to sys.path via conftest, import directly. -from test_device_sync import FakeDev, FakeDeviceFactory, FakeDevices, FakeFolderFactory # noqa: E402 +from indigo_fakes import FakeDev, FakeDeviceFactory, FakeDevices, FakeFolderFactory # noqa: E402 # A sensor node with PowerSource on endpoint 0 and a temperature sensor on endpoint 1.