aiohomematic-test-support 2025.12.51__tar.gz → 2026.1.4__tar.gz
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.
- {aiohomematic_test_support-2025.12.51/aiohomematic_test_support.egg-info → aiohomematic_test_support-2026.1.4}/PKG-INFO +1 -1
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/__init__.py +31 -2
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4/aiohomematic_test_support.egg-info}/PKG-INFO +1 -1
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/aiohomematic_test_support.egg-info/SOURCES.txt +4 -0
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/const.py +1 -1
- aiohomematic_test_support-2026.1.4/event_capture.py +256 -0
- aiohomematic_test_support-2026.1.4/event_mock.py +300 -0
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/factory.py +6 -6
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/helper.py +2 -2
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/mock.py +3 -4
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/MANIFEST.in +0 -0
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/README.md +0 -0
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/aiohomematic_test_support.egg-info/dependency_links.txt +0 -0
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/aiohomematic_test_support.egg-info/top_level.txt +0 -0
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/data/device_translation.json +0 -0
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/data/full_session_randomized_ccu.zip +0 -0
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/data/full_session_randomized_pydevccu.zip +0 -0
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/py.typed +0 -0
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/pyproject.toml +0 -0
- {aiohomematic_test_support-2025.12.51 → aiohomematic_test_support-2026.1.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiohomematic-test-support
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2026.1.4
|
|
4
4
|
Summary: Support-only package for AioHomematic (tests/dev). Not part of production builds.
|
|
5
5
|
Author-email: SukramJ <sukramj@icloud.com>, Daniel Perna <danielperna84@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/SukramJ/aiohomematic
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
|
-
# Copyright (c) 2021-
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
3
|
"""
|
|
4
4
|
Test support infrastructure for aiohomematic.
|
|
5
5
|
|
|
@@ -18,6 +18,8 @@ Key Components
|
|
|
18
18
|
playback support for deterministic testing.
|
|
19
19
|
- **helper**: Test helper utilities for common testing operations.
|
|
20
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.
|
|
21
23
|
|
|
22
24
|
Usage Example
|
|
23
25
|
-------------
|
|
@@ -35,6 +37,33 @@ Using the factory to create a test central with session playback:
|
|
|
35
37
|
device = central.device_coordinator.get_device_by_address("VCU0000001")
|
|
36
38
|
await central.stop()
|
|
37
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
|
+
|
|
38
67
|
The session player replays pre-recorded backend responses, enabling fast and
|
|
39
68
|
reproducible tests without backend dependencies.
|
|
40
69
|
|
|
@@ -47,4 +76,4 @@ test dependencies to access test support functionality.
|
|
|
47
76
|
|
|
48
77
|
from __future__ import annotations
|
|
49
78
|
|
|
50
|
-
__version__ = "
|
|
79
|
+
__version__ = "2026.1.4"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiohomematic-test-support
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2026.1.4
|
|
4
4
|
Summary: Support-only package for AioHomematic (tests/dev). Not part of production builds.
|
|
5
5
|
Author-email: SukramJ <sukramj@icloud.com>, Daniel Perna <danielperna84@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/SukramJ/aiohomematic
|
|
@@ -2,6 +2,8 @@ MANIFEST.in
|
|
|
2
2
|
README.md
|
|
3
3
|
__init__.py
|
|
4
4
|
const.py
|
|
5
|
+
event_capture.py
|
|
6
|
+
event_mock.py
|
|
5
7
|
factory.py
|
|
6
8
|
helper.py
|
|
7
9
|
mock.py
|
|
@@ -9,6 +11,8 @@ py.typed
|
|
|
9
11
|
pyproject.toml
|
|
10
12
|
./__init__.py
|
|
11
13
|
./const.py
|
|
14
|
+
./event_capture.py
|
|
15
|
+
./event_mock.py
|
|
12
16
|
./factory.py
|
|
13
17
|
./helper.py
|
|
14
18
|
./mock.py
|
|
@@ -0,0 +1,256 @@
|
|
|
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
|
+
unsubscribe = event_bus.subscribe(
|
|
156
|
+
event_type=event_type,
|
|
157
|
+
event_key=None,
|
|
158
|
+
handler=self._capture_handler,
|
|
159
|
+
)
|
|
160
|
+
self._unsubscribers.append(unsubscribe)
|
|
161
|
+
|
|
162
|
+
def _capture_handler(self, *, event: Event) -> None:
|
|
163
|
+
"""Handle event by capturing it."""
|
|
164
|
+
self.captured_events.append(event)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class EventSequenceAssertion:
|
|
169
|
+
"""
|
|
170
|
+
Assert events occur in expected sequence.
|
|
171
|
+
|
|
172
|
+
Useful for verifying state machine transitions and multi-step processes.
|
|
173
|
+
|
|
174
|
+
Example:
|
|
175
|
+
-------
|
|
176
|
+
sequence = EventSequenceAssertion(expected_sequence=[
|
|
177
|
+
ConnectionStageChangedEvent,
|
|
178
|
+
ClientStateChangedEvent,
|
|
179
|
+
CentralStateChangedEvent,
|
|
180
|
+
])
|
|
181
|
+
event_bus.subscribe(Event, sequence.on_event)
|
|
182
|
+
|
|
183
|
+
# ... perform actions ...
|
|
184
|
+
|
|
185
|
+
sequence.verify()
|
|
186
|
+
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
expected_sequence: list[type[Event]]
|
|
190
|
+
"""The expected sequence of event types."""
|
|
191
|
+
|
|
192
|
+
captured_types: list[type[Event]] = field(default_factory=list)
|
|
193
|
+
"""Captured event types in order."""
|
|
194
|
+
|
|
195
|
+
def on_event(self, *, event: Event) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Handle event by capturing its type.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
event: The event that was published
|
|
201
|
+
|
|
202
|
+
"""
|
|
203
|
+
self.captured_types.append(type(event))
|
|
204
|
+
|
|
205
|
+
def reset(self) -> None:
|
|
206
|
+
"""Reset captured types for reuse."""
|
|
207
|
+
self.captured_types.clear()
|
|
208
|
+
|
|
209
|
+
def verify(self, *, strict: bool = False) -> None:
|
|
210
|
+
"""
|
|
211
|
+
Verify the captured sequence matches expected.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
strict: If True, require exact sequence match.
|
|
215
|
+
If False, verify expected events appear in order
|
|
216
|
+
(other events may be interspersed).
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
AssertionError: If sequence doesn't match
|
|
220
|
+
|
|
221
|
+
"""
|
|
222
|
+
if strict:
|
|
223
|
+
self._verify_strict()
|
|
224
|
+
else:
|
|
225
|
+
self._verify_subsequence()
|
|
226
|
+
|
|
227
|
+
def _verify_strict(self) -> None:
|
|
228
|
+
"""Verify exact sequence match."""
|
|
229
|
+
if len(self.captured_types) != len(self.expected_sequence):
|
|
230
|
+
raise AssertionError(
|
|
231
|
+
f"Expected {len(self.expected_sequence)} events, "
|
|
232
|
+
f"got {len(self.captured_types)}.\n"
|
|
233
|
+
f"Expected: {[e.__name__ for e in self.expected_sequence]}\n"
|
|
234
|
+
f"Got: {[e.__name__ for e in self.captured_types]}"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
for i, (expected, actual) in enumerate(zip(self.expected_sequence, self.captured_types, strict=True)):
|
|
238
|
+
if expected != actual:
|
|
239
|
+
raise AssertionError(f"Event {i}: expected {expected.__name__}, got {actual.__name__}")
|
|
240
|
+
|
|
241
|
+
def _verify_subsequence(self) -> None:
|
|
242
|
+
"""Verify expected events appear in order (non-strict)."""
|
|
243
|
+
expected_idx = 0
|
|
244
|
+
for actual_type in self.captured_types:
|
|
245
|
+
if expected_idx >= len(self.expected_sequence):
|
|
246
|
+
break
|
|
247
|
+
if actual_type == self.expected_sequence[expected_idx]:
|
|
248
|
+
expected_idx += 1
|
|
249
|
+
|
|
250
|
+
if expected_idx < len(self.expected_sequence):
|
|
251
|
+
missing = self.expected_sequence[expected_idx:]
|
|
252
|
+
raise AssertionError(
|
|
253
|
+
f"Missing expected events in sequence: {[e.__name__ for e in missing]}\n"
|
|
254
|
+
f"Expected: {[e.__name__ for e in self.expected_sequence]}\n"
|
|
255
|
+
f"Got: {[e.__name__ for e in self.captured_types]}"
|
|
256
|
+
)
|
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
|
-
# Copyright (c) 2021-
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
3
|
"""
|
|
4
4
|
Test factories for creating CentralUnit and Client instances.
|
|
5
5
|
|
|
@@ -43,16 +43,16 @@ from unittest.mock import MagicMock, Mock, patch
|
|
|
43
43
|
from aiohttp import ClientSession
|
|
44
44
|
|
|
45
45
|
from aiohomematic.central import CentralConfig, CentralUnit
|
|
46
|
-
from aiohomematic.central.
|
|
46
|
+
from aiohomematic.central.events import (
|
|
47
47
|
DataPointsCreatedEvent,
|
|
48
48
|
DeviceLifecycleEvent,
|
|
49
49
|
DeviceLifecycleEventType,
|
|
50
50
|
DeviceTriggerEvent,
|
|
51
|
-
|
|
51
|
+
SystemStatusChangedEvent,
|
|
52
52
|
)
|
|
53
53
|
from aiohomematic.client import ClientConfig, InterfaceConfig
|
|
54
54
|
from aiohomematic.const import LOCAL_HOST, Interface, OptionalSettings
|
|
55
|
-
from aiohomematic.interfaces
|
|
55
|
+
from aiohomematic.interfaces import ClientProtocol
|
|
56
56
|
from aiohomematic_test_support import const
|
|
57
57
|
from aiohomematic_test_support.mock import SessionPlayer, get_client_session, get_mock, get_xml_rpc_proxy
|
|
58
58
|
|
|
@@ -171,7 +171,7 @@ class FactoryWithClient:
|
|
|
171
171
|
"""Handle device trigger events."""
|
|
172
172
|
self.ha_event_mock(event)
|
|
173
173
|
|
|
174
|
-
def _system_status_event_handler(event:
|
|
174
|
+
def _system_status_event_handler(event: SystemStatusChangedEvent) -> None:
|
|
175
175
|
"""Handle system status events (issues, state changes)."""
|
|
176
176
|
self.ha_event_mock(event)
|
|
177
177
|
|
|
@@ -192,7 +192,7 @@ class FactoryWithClient:
|
|
|
192
192
|
)
|
|
193
193
|
self._event_bus_unsubscribe_callbacks.append(
|
|
194
194
|
central.event_bus.subscribe(
|
|
195
|
-
event_type=
|
|
195
|
+
event_type=SystemStatusChangedEvent, event_key=None, handler=_system_status_event_handler
|
|
196
196
|
)
|
|
197
197
|
)
|
|
198
198
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
|
-
# Copyright (c) 2021-
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
3
|
"""Helpers for tests."""
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
@@ -13,7 +13,7 @@ import orjson
|
|
|
13
13
|
|
|
14
14
|
from aiohomematic.central import CentralUnit
|
|
15
15
|
from aiohomematic.const import UTF_8
|
|
16
|
-
from aiohomematic.interfaces
|
|
16
|
+
from aiohomematic.interfaces import CustomDataPointProtocol
|
|
17
17
|
|
|
18
18
|
_LOGGER = logging.getLogger(__name__)
|
|
19
19
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
|
-
# Copyright (c) 2021-
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
3
|
"""
|
|
4
4
|
Mock implementations for RPC clients with session playback.
|
|
5
5
|
|
|
@@ -53,13 +53,12 @@ from aiohttp import ClientSession
|
|
|
53
53
|
import orjson
|
|
54
54
|
|
|
55
55
|
from aiohomematic.central import CentralUnit
|
|
56
|
-
from aiohomematic.client import BaseRpcProxy
|
|
57
|
-
from aiohomematic.client.circuit_breaker import CircuitBreaker
|
|
56
|
+
from aiohomematic.client import BaseRpcProxy, CircuitBreaker
|
|
58
57
|
from aiohomematic.client.json_rpc import _JsonKey, _JsonRpcMethod
|
|
59
58
|
from aiohomematic.client.rpc_proxy import _RpcMethod
|
|
60
59
|
from aiohomematic.const import UTF_8, DataOperationResult, Parameter, ParamsetKey, RPCType
|
|
61
60
|
from aiohomematic.property_decorators import DelegatedProperty
|
|
62
|
-
from aiohomematic.store
|
|
61
|
+
from aiohomematic.store import cleanup_params_for_session, freeze_params, unfreeze_params
|
|
63
62
|
from aiohomematic_test_support import const
|
|
64
63
|
|
|
65
64
|
_LOGGER = logging.getLogger(__name__)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|