aiohomematic-test-support 2025.12.42__py3-none-any.whl → 2026.1.23__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
1
3
  """
2
4
  Test support infrastructure for aiohomematic.
3
5
 
@@ -16,6 +18,8 @@ Key Components
16
18
  playback support for deterministic testing.
17
19
  - **helper**: Test helper utilities for common testing operations.
18
20
  - **const**: Test-specific constants and configuration values.
21
+ - **event_capture**: Event capture and assertion utilities for behavior testing.
22
+ - **event_mock**: Event-driven mock server for test triggers.
19
23
 
20
24
  Usage Example
21
25
  -------------
@@ -33,6 +37,33 @@ Using the factory to create a test central with session playback:
33
37
  device = central.device_coordinator.get_device_by_address("VCU0000001")
34
38
  await central.stop()
35
39
 
40
+ Using EventCapture for behavior-focused testing:
41
+
42
+ from aiohomematic_test_support.event_capture import EventCapture
43
+ from aiohomematic.central.events import CircuitBreakerTrippedEvent
44
+
45
+ capture = EventCapture()
46
+ capture.subscribe_to(central.event_bus, CircuitBreakerTrippedEvent)
47
+
48
+ # ... trigger failures ...
49
+
50
+ capture.assert_event_emitted(CircuitBreakerTrippedEvent, failure_count=5)
51
+ capture.cleanup()
52
+
53
+ Using EventDrivenMockServer for event-triggered test behavior:
54
+
55
+ from aiohomematic_test_support.event_mock import EventDrivenMockServer
56
+ from aiohomematic.central.events import DataRefreshTriggeredEvent
57
+
58
+ mock_server = EventDrivenMockServer(event_bus=central.event_bus)
59
+ mock_server.when(DataRefreshTriggeredEvent).then_call(
60
+ lambda event: inject_mock_data()
61
+ )
62
+
63
+ # ... trigger refresh ...
64
+
65
+ mock_server.cleanup()
66
+
36
67
  The session player replays pre-recorded backend responses, enabling fast and
37
68
  reproducible tests without backend dependencies.
38
69
 
@@ -45,4 +76,4 @@ test dependencies to access test support functionality.
45
76
 
46
77
  from __future__ import annotations
47
78
 
48
- __version__ = "2025.12.42"
79
+ __version__ = "2026.1.23"
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
1
3
  """Constants for tests."""
2
4
 
3
5
  from __future__ import annotations
@@ -0,0 +1,257 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Event capture utilities for test verification.
5
+
6
+ This module provides tools for capturing and asserting events in tests,
7
+ enabling behavior-focused testing through event verification.
8
+
9
+ Public API
10
+ ----------
11
+ - EventCapture: Capture events for test verification
12
+ - EventSequenceAssertion: Verify event ordering
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+ from typing import TYPE_CHECKING, Any, TypeVar
20
+
21
+ if TYPE_CHECKING:
22
+ from aiohomematic.central.events import Event, EventBus
23
+
24
+ T = TypeVar("T", bound="Event")
25
+
26
+
27
+ @dataclass
28
+ class EventCapture:
29
+ """
30
+ Capture events from EventBus for test verification.
31
+
32
+ Provides methods to subscribe to events, capture them, and make assertions
33
+ about what events were emitted during test execution.
34
+
35
+ Example:
36
+ -------
37
+ capture = EventCapture()
38
+ capture.subscribe_to(event_bus, CircuitBreakerTrippedEvent)
39
+
40
+ # ... perform test actions ...
41
+
42
+ capture.assert_event_emitted(
43
+ CircuitBreakerTrippedEvent,
44
+ interface_id="test",
45
+ failure_count=5,
46
+ )
47
+ capture.cleanup()
48
+
49
+ """
50
+
51
+ captured_events: list[Event] = field(default_factory=list)
52
+ """List of all captured events."""
53
+
54
+ _unsubscribers: list[Callable[[], None]] = field(default_factory=list)
55
+ """Unsubscribe callbacks for cleanup."""
56
+
57
+ def assert_event_emitted(
58
+ self,
59
+ *,
60
+ event_type: type[Event],
61
+ count: int | None = None,
62
+ **expected_attrs: Any,
63
+ ) -> None:
64
+ """
65
+ Assert that an event with specific attributes was emitted.
66
+
67
+ Args:
68
+ event_type: The expected event type
69
+ count: If provided, assert exactly this many events were emitted
70
+ **expected_attrs: Expected attribute values on the event
71
+
72
+ Raises:
73
+ AssertionError: If no matching event found or count mismatch
74
+
75
+ """
76
+ events = self.get_events_of_type(event_type=event_type)
77
+
78
+ if count is not None and len(events) != count:
79
+ raise AssertionError(f"Expected {count} {event_type.__name__} events, got {len(events)}")
80
+
81
+ if not expected_attrs:
82
+ if not events:
83
+ raise AssertionError(f"No {event_type.__name__} events captured")
84
+ return
85
+
86
+ for event in events:
87
+ if all(getattr(event, k, None) == v for k, v in expected_attrs.items()):
88
+ return
89
+
90
+ raise AssertionError(
91
+ f"No {event_type.__name__} found with attributes {expected_attrs}. Captured {len(events)} events: {events}"
92
+ )
93
+
94
+ def assert_no_event(self, *, event_type: type[Event]) -> None:
95
+ """
96
+ Assert that no events of a specific type were emitted.
97
+
98
+ Args:
99
+ event_type: The event type that should not exist
100
+
101
+ Raises:
102
+ AssertionError: If any events of that type were captured
103
+
104
+ """
105
+ if events := self.get_events_of_type(event_type=event_type):
106
+ raise AssertionError(f"Expected no {event_type.__name__} events, but found {len(events)}: {events}")
107
+
108
+ def cleanup(self) -> None:
109
+ """Unsubscribe from all events and clear captured events."""
110
+ for unsub in self._unsubscribers:
111
+ unsub()
112
+ self._unsubscribers.clear()
113
+ self.captured_events.clear()
114
+
115
+ def clear(self) -> None:
116
+ """Clear all captured events."""
117
+ self.captured_events.clear()
118
+
119
+ def get_event_count(self, *, event_type: type[Event]) -> int:
120
+ """
121
+ Return count of captured events of a specific type.
122
+
123
+ Args:
124
+ event_type: The event type to count
125
+
126
+ Returns:
127
+ Number of events of that type
128
+
129
+ """
130
+ return len(self.get_events_of_type(event_type=event_type))
131
+
132
+ def get_events_of_type(self, *, event_type: type[T]) -> list[T]:
133
+ """
134
+ Return all captured events of a specific type.
135
+
136
+ Args:
137
+ event_type: The event type to filter by
138
+
139
+ Returns:
140
+ List of events matching the type
141
+
142
+ """
143
+ return [e for e in self.captured_events if isinstance(e, event_type)]
144
+
145
+ def subscribe_to(self, event_bus: EventBus, *event_types: type[Event]) -> None:
146
+ """
147
+ Subscribe to specific event types on the EventBus.
148
+
149
+ Args:
150
+ event_bus: The EventBus to subscribe to
151
+ *event_types: Event types to capture
152
+
153
+ """
154
+ for event_type in event_types:
155
+ self._unsubscribers.append(
156
+ event_bus.subscribe(
157
+ event_type=event_type,
158
+ event_key=None,
159
+ handler=self._capture_handler,
160
+ )
161
+ )
162
+
163
+ def _capture_handler(self, *, event: Event) -> None:
164
+ """Handle event by capturing it."""
165
+ self.captured_events.append(event)
166
+
167
+
168
+ @dataclass
169
+ class EventSequenceAssertion:
170
+ """
171
+ Assert events occur in expected sequence.
172
+
173
+ Useful for verifying state machine transitions and multi-step processes.
174
+
175
+ Example:
176
+ -------
177
+ sequence = EventSequenceAssertion(expected_sequence=[
178
+ ConnectionStageChangedEvent,
179
+ ClientStateChangedEvent,
180
+ CentralStateChangedEvent,
181
+ ])
182
+ event_bus.subscribe(Event, sequence.on_event)
183
+
184
+ # ... perform actions ...
185
+
186
+ sequence.verify()
187
+
188
+ """
189
+
190
+ expected_sequence: list[type[Event]]
191
+ """The expected sequence of event types."""
192
+
193
+ captured_types: list[type[Event]] = field(default_factory=list)
194
+ """Captured event types in order."""
195
+
196
+ def on_event(self, *, event: Event) -> None:
197
+ """
198
+ Handle event by capturing its type.
199
+
200
+ Args:
201
+ event: The event that was published
202
+
203
+ """
204
+ self.captured_types.append(type(event))
205
+
206
+ def reset(self) -> None:
207
+ """Reset captured types for reuse."""
208
+ self.captured_types.clear()
209
+
210
+ def verify(self, *, strict: bool = False) -> None:
211
+ """
212
+ Verify the captured sequence matches expected.
213
+
214
+ Args:
215
+ strict: If True, require exact sequence match.
216
+ If False, verify expected events appear in order
217
+ (other events may be interspersed).
218
+
219
+ Raises:
220
+ AssertionError: If sequence doesn't match
221
+
222
+ """
223
+ if strict:
224
+ self._verify_strict()
225
+ else:
226
+ self._verify_subsequence()
227
+
228
+ def _verify_strict(self) -> None:
229
+ """Verify exact sequence match."""
230
+ if len(self.captured_types) != len(self.expected_sequence):
231
+ raise AssertionError(
232
+ f"Expected {len(self.expected_sequence)} events, "
233
+ f"got {len(self.captured_types)}.\n"
234
+ f"Expected: {[e.__name__ for e in self.expected_sequence]}\n"
235
+ f"Got: {[e.__name__ for e in self.captured_types]}"
236
+ )
237
+
238
+ for i, (expected, actual) in enumerate(zip(self.expected_sequence, self.captured_types, strict=True)):
239
+ if expected != actual:
240
+ raise AssertionError(f"Event {i}: expected {expected.__name__}, got {actual.__name__}")
241
+
242
+ def _verify_subsequence(self) -> None:
243
+ """Verify expected events appear in order (non-strict)."""
244
+ expected_idx = 0
245
+ for actual_type in self.captured_types:
246
+ if expected_idx >= len(self.expected_sequence):
247
+ break
248
+ if actual_type == self.expected_sequence[expected_idx]:
249
+ expected_idx += 1
250
+
251
+ if expected_idx < len(self.expected_sequence):
252
+ missing = self.expected_sequence[expected_idx:]
253
+ raise AssertionError(
254
+ f"Missing expected events in sequence: {[e.__name__ for e in missing]}\n"
255
+ f"Expected: {[e.__name__ for e in self.expected_sequence]}\n"
256
+ f"Got: {[e.__name__ for e in self.captured_types]}"
257
+ )
@@ -0,0 +1,300 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Event-driven mock server for test triggers.
5
+
6
+ Overview
7
+ --------
8
+ This module provides a mock server that responds to events by injecting
9
+ data or triggering actions. This enables event-based test orchestration
10
+ where test behavior is triggered by events rather than explicit calls.
11
+
12
+ The EventDrivenMockServer allows tests to:
13
+ - Configure responses to specific event types
14
+ - Inject mock data when certain events are published
15
+ - Build complex test scenarios with event-driven behavior
16
+
17
+ Public API
18
+ ----------
19
+ - EventDrivenMockServer: Main mock server class
20
+ - ResponseBuilder: Fluent builder for configuring responses
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass, field
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ if TYPE_CHECKING:
30
+ from aiohomematic.central.events import Event, EventBus
31
+
32
+
33
+ @dataclass
34
+ class MockAction:
35
+ """
36
+ An action to perform when an event is received.
37
+
38
+ Attributes:
39
+ ----------
40
+ handler: The callback to invoke when the event is received
41
+ filter_fn: Optional filter function to match specific events
42
+ one_shot: If True, the action is removed after first invocation
43
+
44
+ """
45
+
46
+ handler: Callable[[Any], Any]
47
+ filter_fn: Callable[[Any], bool] | None = None
48
+ one_shot: bool = False
49
+
50
+
51
+ @dataclass
52
+ class EventDrivenMockServer:
53
+ """
54
+ Mock server that responds to events.
55
+
56
+ Provides a fluent API for configuring responses to specific event types.
57
+ When an event is published that matches a configured response, the
58
+ corresponding action is executed.
59
+
60
+ Example Usage
61
+ -------------
62
+ mock_server = EventDrivenMockServer(event_bus=central.event_bus)
63
+
64
+ # Configure response to inject data when refresh is triggered
65
+ mock_server.when(DataRefreshTriggeredEvent).then_call(
66
+ lambda event: inject_mock_data()
67
+ )
68
+
69
+ # Configure one-shot response
70
+ mock_server.when(CircuitBreakerTrippedEvent).once().then_call(
71
+ lambda event: trigger_recovery()
72
+ )
73
+
74
+ # Later, cleanup:
75
+ mock_server.cleanup()
76
+
77
+ """
78
+
79
+ _event_bus: EventBus = field(repr=False)
80
+ """The EventBus to subscribe to."""
81
+
82
+ _responses: dict[type[Event], list[MockAction]] = field(default_factory=dict)
83
+ """Mapping of event types to their configured actions."""
84
+
85
+ _unsubscribers: list[Callable[[], None]] = field(default_factory=list)
86
+ """Unsubscribe callbacks for cleanup."""
87
+
88
+ _invocation_count: dict[type[Event], int] = field(default_factory=dict)
89
+ """Count of invocations per event type."""
90
+
91
+ def cleanup(self) -> None:
92
+ """Unsubscribe from all events and clear responses."""
93
+ for unsub in self._unsubscribers:
94
+ unsub()
95
+ self._unsubscribers.clear()
96
+ self._responses.clear()
97
+ self._invocation_count.clear()
98
+
99
+ def get_invocation_count(self, *, event_type: type[Event]) -> int:
100
+ """
101
+ Return the number of times handlers were invoked for an event type.
102
+
103
+ Args:
104
+ ----
105
+ event_type: The event type to query
106
+
107
+ Returns:
108
+ -------
109
+ Number of handler invocations
110
+
111
+ """
112
+ return self._invocation_count.get(event_type, 0)
113
+
114
+ def when[T: Event](self, *, event_type: type[T]) -> ResponseBuilder[T]:
115
+ """
116
+ Start configuring a response for an event type.
117
+
118
+ Args:
119
+ ----
120
+ event_type: The event type to respond to
121
+
122
+ Returns:
123
+ -------
124
+ A ResponseBuilder for configuring the response
125
+
126
+ """
127
+ return ResponseBuilder(mock_server=self, event_type=event_type)
128
+
129
+ def _add_response(
130
+ self,
131
+ *,
132
+ event_type: type[Event],
133
+ action: MockAction,
134
+ ) -> None:
135
+ """
136
+ Add a response for an event type.
137
+
138
+ If this is the first response for this event type, subscribe to it.
139
+
140
+ Args:
141
+ ----
142
+ event_type: The event type to respond to
143
+ action: The action to perform
144
+
145
+ """
146
+ if event_type not in self._responses:
147
+ self._responses[event_type] = []
148
+ # Subscribe to this event type
149
+ unsub = self._event_bus.subscribe(
150
+ event_type=event_type,
151
+ event_key=None,
152
+ handler=lambda event, et=event_type: self._on_event(event=event, event_type=et),
153
+ )
154
+ self._unsubscribers.append(unsub)
155
+
156
+ self._responses[event_type].append(action)
157
+
158
+ def _on_event(self, *, event: Event, event_type: type[Event]) -> None:
159
+ """
160
+ Handle an event by invoking matching responses.
161
+
162
+ Args:
163
+ ----
164
+ event: The event that was published
165
+ event_type: The event type (for lookup)
166
+
167
+ """
168
+ if event_type not in self._responses:
169
+ return
170
+
171
+ # Track invocations
172
+ self._invocation_count[event_type] = self._invocation_count.get(event_type, 0) + 1
173
+
174
+ # Process actions (copy list to allow modification during iteration)
175
+ actions_to_remove: list[MockAction] = []
176
+ for action in list(self._responses[event_type]):
177
+ # Check filter if present
178
+ if action.filter_fn is not None and not action.filter_fn(event):
179
+ continue
180
+
181
+ # Invoke handler
182
+ action.handler(event)
183
+
184
+ # Mark one-shot actions for removal
185
+ if action.one_shot:
186
+ actions_to_remove.append(action)
187
+
188
+ # Remove one-shot actions
189
+ for action in actions_to_remove:
190
+ self._responses[event_type].remove(action)
191
+
192
+
193
+ @dataclass
194
+ class ResponseBuilder[T: "Event"]:
195
+ """
196
+ Fluent builder for configuring mock responses.
197
+
198
+ Provides a chainable API for configuring how the mock server should
199
+ respond to specific events.
200
+
201
+ Example:
202
+ -------
203
+ mock_server.when(SomeEvent)
204
+ .matching(lambda e: e.interface_id == "test")
205
+ .once()
206
+ .then_call(handler_fn)
207
+
208
+ """
209
+
210
+ mock_server: EventDrivenMockServer
211
+ """The mock server to configure."""
212
+
213
+ event_type: type[T]
214
+ """The event type being configured."""
215
+
216
+ _filter_fn: Callable[[T], bool] | None = None
217
+ """Optional filter function."""
218
+
219
+ _one_shot: bool = False
220
+ """Whether this is a one-shot response."""
221
+
222
+ def matching(self, *, filter_fn: Callable[[T], bool]) -> ResponseBuilder[T]:
223
+ """
224
+ Add a filter to match specific events.
225
+
226
+ Args:
227
+ ----
228
+ filter_fn: Function that returns True for matching events
229
+
230
+ Returns:
231
+ -------
232
+ Self for chaining
233
+
234
+ """
235
+ self._filter_fn = filter_fn
236
+ return self
237
+
238
+ def once(self) -> ResponseBuilder[T]:
239
+ """
240
+ Make this a one-shot response (removed after first invocation).
241
+
242
+ Returns
243
+ -------
244
+ Self for chaining
245
+
246
+ """
247
+ self._one_shot = True
248
+ return self
249
+
250
+ def then_call(self, *, handler: Callable[[T], Any]) -> None:
251
+ """
252
+ Configure the handler to call when the event is received.
253
+
254
+ Args:
255
+ ----
256
+ handler: Function to call with the event
257
+
258
+ """
259
+ self.mock_server._add_response( # noqa: SLF001 # pylint: disable=protected-access
260
+ event_type=self.event_type,
261
+ action=MockAction(
262
+ handler=handler,
263
+ filter_fn=self._filter_fn,
264
+ one_shot=self._one_shot,
265
+ ),
266
+ )
267
+
268
+ def then_publish(self, *, event_factory: Callable[[T], Event]) -> None:
269
+ """
270
+ Configure the mock to publish another event when this event is received.
271
+
272
+ Args:
273
+ ----
274
+ event_factory: Function that creates the event to publish
275
+
276
+ """
277
+
278
+ def publish_handler(event: T) -> None:
279
+ new_event = event_factory(event)
280
+ self.mock_server._event_bus.publish_sync(event=new_event) # noqa: SLF001 # pylint: disable=protected-access
281
+
282
+ self.then_call(handler=publish_handler)
283
+
284
+
285
+ def create_event_mock_server(*, event_bus: EventBus) -> EventDrivenMockServer:
286
+ """
287
+ Create an EventDrivenMockServer instance.
288
+
289
+ Factory function for creating an event-driven mock server.
290
+
291
+ Args:
292
+ ----
293
+ event_bus: The EventBus to subscribe to
294
+
295
+ Returns:
296
+ -------
297
+ Configured EventDrivenMockServer instance
298
+
299
+ """
300
+ return EventDrivenMockServer(_event_bus=event_bus)
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
1
3
  """
2
4
  Test factories for creating CentralUnit and Client instances.
3
5
 
@@ -41,16 +43,16 @@ from unittest.mock import MagicMock, Mock, patch
41
43
  from aiohttp import ClientSession
42
44
 
43
45
  from aiohomematic.central import CentralConfig, CentralUnit
44
- from aiohomematic.central.integration_events import (
46
+ from aiohomematic.central.events import (
45
47
  DataPointsCreatedEvent,
46
48
  DeviceLifecycleEvent,
47
49
  DeviceLifecycleEventType,
48
50
  DeviceTriggerEvent,
49
- SystemStatusEvent,
51
+ SystemStatusChangedEvent,
50
52
  )
51
- from aiohomematic.client import ClientConfig, InterfaceConfig
53
+ from aiohomematic.client import InterfaceConfig, create_client as create_client_func
52
54
  from aiohomematic.const import LOCAL_HOST, Interface, OptionalSettings
53
- from aiohomematic.interfaces.client import ClientProtocol
55
+ from aiohomematic.interfaces import ClientProtocol
54
56
  from aiohomematic_test_support import const
55
57
  from aiohomematic_test_support.mock import SessionPlayer, get_client_session, get_mock, get_xml_rpc_proxy
56
58
 
@@ -116,10 +118,17 @@ class FactoryWithClient:
116
118
 
117
119
  # Optionally patch client creation to return a mocked client
118
120
  if self._do_mock_client:
119
- _orig_create_client = ClientConfig.create_client
120
-
121
- async def _mocked_create_client(config: ClientConfig) -> ClientProtocol | Mock:
122
- real_client = await _orig_create_client(config)
121
+ _orig_create_client = create_client_func
122
+
123
+ async def _mocked_create_client(
124
+ *,
125
+ client_deps: Any,
126
+ interface_config: InterfaceConfig,
127
+ ) -> ClientProtocol | Mock:
128
+ real_client = await _orig_create_client(
129
+ client_deps=client_deps,
130
+ interface_config=interface_config,
131
+ )
123
132
  return cast(
124
133
  Mock,
125
134
  get_mock(
@@ -129,7 +138,7 @@ class FactoryWithClient:
129
138
  ),
130
139
  )
131
140
 
132
- p3 = patch("aiohomematic.client.ClientConfig.create_client", _mocked_create_client)
141
+ p3 = patch("aiohomematic.client.create_client", _mocked_create_client)
133
142
  p3.start()
134
143
  self._patches.append(p3)
135
144
 
@@ -142,7 +151,7 @@ class FactoryWithClient:
142
151
  async def get_raw_central(self) -> CentralUnit:
143
152
  """Return a central based on give address_device_translation."""
144
153
  interface_configs = self._interface_configs if self._interface_configs else set()
145
- central = CentralConfig(
154
+ central = await CentralConfig(
146
155
  name=const.CENTRAL_NAME,
147
156
  host=const.CCU_HOST,
148
157
  username=const.CCU_USERNAME,
@@ -169,7 +178,7 @@ class FactoryWithClient:
169
178
  """Handle device trigger events."""
170
179
  self.ha_event_mock(event)
171
180
 
172
- def _system_status_event_handler(event: SystemStatusEvent) -> None:
181
+ def _system_status_event_handler(event: SystemStatusChangedEvent) -> None:
173
182
  """Handle system status events (issues, state changes)."""
174
183
  self.ha_event_mock(event)
175
184
 
@@ -190,7 +199,7 @@ class FactoryWithClient:
190
199
  )
191
200
  self._event_bus_unsubscribe_callbacks.append(
192
201
  central.event_bus.subscribe(
193
- event_type=SystemStatusEvent, event_key=None, handler=_system_status_event_handler
202
+ event_type=SystemStatusChangedEvent, event_key=None, handler=_system_status_event_handler
194
203
  )
195
204
  )
196
205
 
@@ -293,7 +302,7 @@ async def get_pydev_ccu_central_unit_full(
293
302
  )
294
303
  }
295
304
 
296
- central = CentralConfig(
305
+ central = await CentralConfig(
297
306
  name=const.CENTRAL_NAME,
298
307
  host=const.CCU_HOST,
299
308
  username=const.CCU_USERNAME,
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
1
3
  """Helpers for tests."""
2
4
 
3
5
  from __future__ import annotations
@@ -11,7 +13,7 @@ import orjson
11
13
 
12
14
  from aiohomematic.central import CentralUnit
13
15
  from aiohomematic.const import UTF_8
14
- from aiohomematic.interfaces.model import CustomDataPointProtocol
16
+ from aiohomematic.interfaces import CustomDataPointProtocol
15
17
 
16
18
  _LOGGER = logging.getLogger(__name__)
17
19
 
@@ -19,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
19
21
  # pylint: disable=protected-access
20
22
 
21
23
 
22
- def _load_json_file(anchor: str, resource: str, file_name: str) -> Any | None:
24
+ def _load_json_file(anchor: str, resource: str, file_name: str) -> Any | None: # kwonly: disable
23
25
  """Load json file from disk into dict."""
24
26
  package_path = str(importlib.resources.files(anchor))
25
27
  with open(
@@ -29,7 +31,7 @@ def _load_json_file(anchor: str, resource: str, file_name: str) -> Any | None:
29
31
  return orjson.loads(fptr.read())
30
32
 
31
33
 
32
- def get_prepared_custom_data_point(
34
+ def get_prepared_custom_data_point( # kwonly: disable
33
35
  central: CentralUnit, address: str, channel_no: int
34
36
  ) -> CustomDataPointProtocol | None:
35
37
  """Return the hm custom_data_point."""
@@ -40,7 +42,7 @@ def get_prepared_custom_data_point(
40
42
  return None
41
43
 
42
44
 
43
- def load_device_description(file_name: str) -> Any:
45
+ def load_device_description(file_name: str) -> Any: # kwonly: disable
44
46
  """Load device description."""
45
47
  dev_desc = _load_json_file(anchor="pydevccu", resource="device_descriptions", file_name=file_name)
46
48
  assert dev_desc
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
1
3
  """
2
4
  Mock implementations for RPC clients with session playback.
3
5
 
@@ -50,12 +52,14 @@ import zipfile
50
52
  from aiohttp import ClientSession
51
53
  import orjson
52
54
 
55
+ from aiohomematic.async_support import Looper
53
56
  from aiohomematic.central import CentralUnit
54
- from aiohomematic.client import BaseRpcProxy
57
+ from aiohomematic.client import BaseRpcProxy, CircuitBreaker
55
58
  from aiohomematic.client.json_rpc import _JsonKey, _JsonRpcMethod
56
59
  from aiohomematic.client.rpc_proxy import _RpcMethod
57
60
  from aiohomematic.const import UTF_8, DataOperationResult, Parameter, ParamsetKey, RPCType
58
- from aiohomematic.store.persistent import _cleanup_params_for_session, _freeze_params, _unfreeze_params
61
+ from aiohomematic.property_decorators import DelegatedProperty
62
+ from aiohomematic.store import cleanup_params_for_session, freeze_params, unfreeze_params
59
63
  from aiohomematic_test_support import const
60
64
 
61
65
  _LOGGER = logging.getLogger(__name__)
@@ -66,6 +70,7 @@ _LOGGER = logging.getLogger(__name__)
66
70
  def _get_not_mockable_method_names(*, instance: Any, exclude_methods: set[str]) -> set[str]:
67
71
  """Return all relevant method names for mocking."""
68
72
  methods: set[str] = set(_get_properties(data_object=instance, decorator=property))
73
+ methods |= _get_properties(data_object=instance, decorator=DelegatedProperty)
69
74
 
70
75
  for method in dir(instance):
71
76
  if method in exclude_methods:
@@ -186,6 +191,16 @@ def get_client_session( # noqa: C901
186
191
  }
187
192
  )
188
193
 
194
+ if "fetch_all_device_data" in params[_JsonKey.SCRIPT]:
195
+ # Return empty device data dict for InterfaceClient
196
+ return _MockResponse(
197
+ json_data={
198
+ _JsonKey.ID: 0,
199
+ _JsonKey.RESULT: {},
200
+ _JsonKey.ERROR: None,
201
+ }
202
+ )
203
+
189
204
  if method == _JsonRpcMethod.INTERFACE_SET_VALUE:
190
205
  await self._central.event_coordinator.data_point_event(
191
206
  interface_id=params[_JsonKey.INTERFACE],
@@ -270,11 +285,18 @@ def get_xml_rpc_proxy( # noqa: C901
270
285
  self._player = player
271
286
  self._supported_methods: tuple[str, ...] = ()
272
287
  self._central: CentralUnit | None = None
288
+ # Real CircuitBreaker to provide actual metrics for tests
289
+ self._circuit_breaker = CircuitBreaker(interface_id="mock-interface", task_scheduler=Looper())
273
290
 
274
291
  def __getattr__(self, name: str) -> Any:
275
292
  # Start of method chain
276
293
  return _Method(name, self._invoke)
277
294
 
295
+ @property
296
+ def circuit_breaker(self) -> CircuitBreaker:
297
+ """Return the circuit breaker for metrics access."""
298
+ return self._circuit_breaker
299
+
278
300
  @property
279
301
  def supported_methods(self) -> tuple[str, ...]:
280
302
  """Return the supported methods."""
@@ -322,11 +344,11 @@ def get_xml_rpc_proxy( # noqa: C901
322
344
 
323
345
  for dd in devices:
324
346
  if ignore_devices_on_create is not None and (
325
- dd["ADDRESS"] in ignore_devices_on_create or dd["PARENT"] in ignore_devices_on_create
347
+ dd["ADDRESS"] in ignore_devices_on_create or dd.get("PARENT") in ignore_devices_on_create
326
348
  ):
327
349
  continue
328
350
  if address_device_translation is not None:
329
- if dd["ADDRESS"] in address_device_translation or dd["PARENT"] in address_device_translation:
351
+ if dd["ADDRESS"] in address_device_translation or dd.get("PARENT") in address_device_translation:
330
352
  new_devices.append(dd)
331
353
  else:
332
354
  new_devices.append(dd)
@@ -382,7 +404,7 @@ def get_xml_rpc_proxy( # noqa: C901
382
404
  return cast(BaseRpcProxy, _AioXmlRpcProxyFromSession())
383
405
 
384
406
 
385
- def _get_instance_attributes(instance: Any) -> set[str]:
407
+ def _get_instance_attributes(instance: Any) -> set[str]: # kwonly: disable
386
408
  """
387
409
  Get all instance attribute names, supporting both __dict__ and __slots__.
388
410
 
@@ -479,9 +501,11 @@ def get_mock(
479
501
  setattr(instance, attr, getattr(instance._mock_wraps, attr))
480
502
  return instance
481
503
 
482
- # Step 1: Identify all @property decorated attributes on the class
504
+ # Step 1: Identify all @property and DelegatedProperty decorated attributes on the class
483
505
  # These need special handling because MagicMock doesn't delegate property access
484
506
  property_names = _get_properties(data_object=instance, decorator=property)
507
+ # Also include DelegatedProperty descriptors which are now used extensively
508
+ property_names |= _get_properties(data_object=instance, decorator=DelegatedProperty)
485
509
 
486
510
  # Step 2: Create a dynamic MagicMock subclass
487
511
  # We add property descriptors to this subclass that delegate to _mock_wraps.
@@ -644,7 +668,7 @@ class SessionPlayer:
644
668
  except ValueError:
645
669
  continue
646
670
  resp = bucket_by_ts[latest_ts]
647
- params = _unfreeze_params(frozen_params=frozen_params)
671
+ params = unfreeze_params(frozen_params=frozen_params)
648
672
 
649
673
  result.append((params, resp))
650
674
  return result
@@ -689,7 +713,7 @@ class SessionPlayer:
689
713
  return None
690
714
  if not (bucket_by_parameter := bucket_by_method.get(method)):
691
715
  return None
692
- frozen_params = _freeze_params(params=_cleanup_params_for_session(params=params))
716
+ frozen_params = freeze_params(params=cleanup_params_for_session(params=params))
693
717
 
694
718
  # For each parameter, choose the response at the latest timestamp.
695
719
  if (bucket_by_ts := bucket_by_parameter.get(frozen_params)) is None:
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohomematic-test-support
3
- Version: 2025.12.42
3
+ Version: 2026.1.23
4
4
  Summary: Support-only package for AioHomematic (tests/dev). Not part of production builds.
5
- Author-email: SukramJ <sukramj@icloud.com>
5
+ Author-email: SukramJ <sukramj@icloud.com>, Daniel Perna <danielperna84@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/SukramJ/aiohomematic
7
7
  Classifier: Development Status :: 3 - Alpha
8
8
  Classifier: Intended Audience :: Developers
@@ -0,0 +1,15 @@
1
+ aiohomematic_test_support/__init__.py,sha256=xZticN_kTORdNFQgnUH4pA57HNPUKUYQ8Kw0z2xpNtY,2711
2
+ aiohomematic_test_support/const.py,sha256=cYsT9WMnoxRR1zRRUFX8M7A9e0pwS3NcxKK9smXiiw8,6487
3
+ aiohomematic_test_support/event_capture.py,sha256=9giXt8-vbPXUbG0X89Jt5Q8LfPli0y-liLVVUhiw-EM,8002
4
+ aiohomematic_test_support/event_mock.py,sha256=sWtAqMPXxkWFNqslw2wfgXGtSM0kLMm0Lg_qkkAFE4g,8493
5
+ aiohomematic_test_support/factory.py,sha256=B3R_FxZP6zRi3A-CqUmcAjkqTX9GIm-kSEKLx2OW9vE,12472
6
+ aiohomematic_test_support/helper.py,sha256=r63EpCEPmWFdcxiEDR5k22AwbB112DbWJwK66iHLeU4,1468
7
+ aiohomematic_test_support/mock.py,sha256=GYwpwH-mfaobK8DyNj3FFC2zyvATs46sx2l7V5wnqGA,32366
8
+ aiohomematic_test_support/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ aiohomematic_test_support/data/device_translation.json,sha256=owHLpCGvtMJzfCMOMMgnn7-LgN-BMKSYzi4NKINw1WQ,13290
10
+ aiohomematic_test_support/data/full_session_randomized_ccu.zip,sha256=kKHMtJouCskAHkXaP1hHRSoWZO2cZDJ9P_EwuJuywJQ,733017
11
+ aiohomematic_test_support/data/full_session_randomized_pydevccu.zip,sha256=_QFWSP03dkiMFdD_w-R98DS6ur4PYDQXw-DCkbJEGg4,1293240
12
+ aiohomematic_test_support-2026.1.23.dist-info/METADATA,sha256=BGjkXtFTGXYKzmYdb1bMT9TMt3A_AszRMsQSa_FN0FM,575
13
+ aiohomematic_test_support-2026.1.23.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ aiohomematic_test_support-2026.1.23.dist-info/top_level.txt,sha256=KmK-OiDDbrmawIsIgPWNAkpkDfWQnOoumYd9MXAiTHc,26
15
+ aiohomematic_test_support-2026.1.23.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- aiohomematic_test_support/__init__.py,sha256=_61m3I7tUF-q8k6-Vh3nz81vkCAY_W-UUcyhRrTeViw,1642
2
- aiohomematic_test_support/const.py,sha256=tLyvqZJlaDCs1F2OBRnJ94hUB0pu7RKk--CQGlVC_58,6430
3
- aiohomematic_test_support/factory.py,sha256=FoemfxruooVRViShZdW4uxHc09pCIUFSCG_goxvpVmg,12191
4
- aiohomematic_test_support/helper.py,sha256=Ue2tfy10_fiuMjYsc1jYPvo5sEtMF2WVKjvLnTZ0TzU,1360
5
- aiohomematic_test_support/mock.py,sha256=GhJjKXPIYwzmHanKQSEIjGb8-wuixa3n7cFW-m-oiKY,31097
6
- aiohomematic_test_support/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- aiohomematic_test_support/data/device_translation.json,sha256=owHLpCGvtMJzfCMOMMgnn7-LgN-BMKSYzi4NKINw1WQ,13290
8
- aiohomematic_test_support/data/full_session_randomized_ccu.zip,sha256=kKHMtJouCskAHkXaP1hHRSoWZO2cZDJ9P_EwuJuywJQ,733017
9
- aiohomematic_test_support/data/full_session_randomized_pydevccu.zip,sha256=_QFWSP03dkiMFdD_w-R98DS6ur4PYDQXw-DCkbJEGg4,1293240
10
- aiohomematic_test_support-2025.12.42.dist-info/METADATA,sha256=2h9S5x7EQqUEeN0oSnPGMuU8tYvv60HjCWP9hNiGe04,536
11
- aiohomematic_test_support-2025.12.42.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- aiohomematic_test_support-2025.12.42.dist-info/top_level.txt,sha256=KmK-OiDDbrmawIsIgPWNAkpkDfWQnOoumYd9MXAiTHc,26
13
- aiohomematic_test_support-2025.12.42.dist-info/RECORD,,