From 3cc3b461a5ad970dd0c1c94bb32253c555a62bc4 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Tue, 25 Aug 2026 21:10:49 +0100 Subject: [PATCH] fix(tests): wait for the attach instead of assuming it, and unify the inbound Indigo fakes (#304, #306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two test-hygiene fixes, both found by the CI gate added in #305. **#304 — four tests asserted on state they never waited for.** `TestReattach::test_attach_on_a_live_connection_updates_the_status` and three `set_state` siblings did `await client.wait_connected(...)` and then read `client.status` / expected an attach-dependent log line. `wait_connected` is transport-level (`ws_json_client`, `_connected_event`); the ATTACH that populates `status` and sets `_attached` lands later on the run loop. On 3.13 the loop happened to have run it by the assertion, on 3.11 it had not — so the tests passed on 3.13 by luck. Pre-existing, confirmed failing at 913ad44, and invisible until now precisely because no CI ran the suite and everyone develops on 3.13. `BridgeClient` gains `wait_attached(timeout=...)`, mirroring `wait_connected`: an `asyncio.Event` set at all three sites where `_attached` becomes True and cleared in `_mark_disconnected` beside the existing reset. It follows `_attached`, NOT the `attached` property — that property is `_attached and connected` because an `endpoint_map_invalid` refusal leaves a live socket serving nothing, and the distinction is deliberate. The four tests now await that. **No assertion changed** — only what they wait for; a fix that needed an assertion edited would have been the wrong fix. The other 28 `wait_connected` sites are untouched: they test transport state and already passed on 3.11. `.github/workflows/tests.yml` restores `python-version: ["3.11", "3.13"]`. 3.11 is the floor `pyproject.toml` declares and pylint targets; 3.13 is what jarvis runs. Both green now — 3.11 for the first time. **#306 — a drifted copy of the inbound Indigo fakes.** `test_generic_switch.py` carried its own `FakeDev`/`FakeDevices`/ `FakeDeviceFactory`/`FakeFolderFactory` under a header reading "Helpers shared with DeviceSync tests". They were not shared, and had already lost the richer version's `Supports*` state seeding, `replacePluginPropsOnServer` simulation, `fail_replace` rollback, `stateListOrDisplayStateIdChanged` counting, the ADR-0009 device-group model, and real folder creation. The authoritative versions move to `tests/indigo_fakes.py` — a new module rather than `fakes.py`, because `fakes.py` holds the EXPORT-side static doubles and the two families must not be conflated; a separate module makes that structurally unmissable instead of relying on a banner. `test_device_sync`, `test_generic_switch`, and two further importers found by grep (`test_power_source`, `test_integration`, which were reaching into `test_device_sync` directly) now all import from it. One thing merged rather than moved: `initial_states`, which only the generic_switch copy had, seeds `matterButton`'s `lastButtonEvent`/`pressCount` — values derived from neither `Supports*` nor the static state table. Dropping it would have broken that suite. Nothing failed once generic_switch used the richer fake: every mechanism the copy lacked is inert for that suite, so the drift was latent rather than already causing wrong answers. It would not have stayed that way. 3873 passing on BOTH 3.11 and 3.13; pylint 9.48, unchanged. Closes #304 Closes #306 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/tests.yml | 16 +- .../Contents/Info.plist | 2 +- .../Contents/Server Plugin/bridge_client.py | 23 ++ tests/indigo_fakes.py | 322 ++++++++++++++++++ tests/test_bridge_client.py | 8 +- tests/test_device_sync.py | 304 +---------------- tests/test_generic_switch.py | 80 +---- tests/test_integration.py | 2 +- tests/test_power_source.py | 6 +- 9 files changed, 364 insertions(+), 399 deletions(-) create mode 100644 tests/indigo_fakes.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 94f0e60d..6e805516 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 5ae041b8..26abdc9c 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 6e5a7828..fd140999 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 00000000..971a0f92 --- /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 51dde308..c6c5f676 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 2de1fd1f..71e253d3 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 c6ed5cb4..0e7f5d08 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 d96f61e3..25b66b86 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 fc99d86c..278c49a7 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.