Skip to content

Commit d2e0e74

Browse files
test(device): characterization tests for property routing and dirty-data discard (#20)
* test(device): characterization tests for property routing and dirty-data discard * style: pre-commit auto-fix --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 548dd00 commit d2e0e74

1 file changed

Lines changed: 307 additions & 0 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
"""Characterization tests for DreameVacuumDevice._handle_properties and
2+
_message_callback.
3+
4+
Strategy: bare instances via object.__new__ with _ready=True so that the
5+
"not self._ready" branches (capability.load, status mutations) are never
6+
entered. Only data-routing logic is exercised.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import sys
12+
import time
13+
import types
14+
from unittest.mock import MagicMock
15+
16+
# ---------------------------------------------------------------------------
17+
# Native-extension stubs (turbojpeg / py_mini_racer)
18+
# ---------------------------------------------------------------------------
19+
20+
21+
def _ensure_native_stubs() -> None:
22+
"""Install lightweight stand-ins for optional C-extension dependencies."""
23+
if "turbojpeg" not in sys.modules:
24+
tj = types.ModuleType("turbojpeg")
25+
tj.TurboJPEG = type("TurboJPEG", (), {"__init__": lambda self, *a, **k: None}) # type: ignore[attr-defined]
26+
sys.modules["turbojpeg"] = tj
27+
if "py_mini_racer" not in sys.modules:
28+
pmr = types.ModuleType("py_mini_racer")
29+
pmr.MiniRacer = type("MiniRacer", (), {"__init__": lambda self, *a, **k: None}) # type: ignore[attr-defined]
30+
sys.modules["py_mini_racer"] = pmr
31+
32+
33+
_ensure_native_stubs()
34+
35+
from custom_components.dreame_vacuum.dreame.device import DreameVacuumDevice
36+
from custom_components.dreame_vacuum.dreame.vacuum_types import (
37+
DirtyData,
38+
DreameVacuumProperty,
39+
)
40+
41+
# ---------------------------------------------------------------------------
42+
# Constants
43+
# ---------------------------------------------------------------------------
44+
45+
_BATTERY_DID = DreameVacuumProperty.BATTERY_LEVEL.value # int 2
46+
_BATTERY_SIID = 3
47+
_BATTERY_PIID = 1
48+
49+
_MAP_LIST_DID = DreameVacuumProperty.MAP_LIST.value # int 81
50+
51+
# ---------------------------------------------------------------------------
52+
# Builder
53+
# ---------------------------------------------------------------------------
54+
55+
56+
def _bare_device() -> DreameVacuumDevice:
57+
"""Minimal DreameVacuumDevice instance for pure data-routing tests.
58+
59+
Attributes set here are the complete set required by _handle_properties
60+
when _ready=True. Attributes added to satisfy _message_callback are
61+
added in _bare_device_with_message_support().
62+
"""
63+
d: DreameVacuumDevice = object.__new__(DreameVacuumDevice)
64+
d.data = {}
65+
d._dirty_data = {}
66+
d._discard_timeout = 2
67+
d._property_update_callback = {}
68+
d._ready = True
69+
d._last_change = 0.0
70+
d._property_changed = MagicMock()
71+
return d
72+
73+
74+
def _bare_device_with_message_support() -> DreameVacuumDevice:
75+
"""Extends _bare_device with the attributes needed by _message_callback."""
76+
d = _bare_device()
77+
# _default_properties is a list[DreameVacuumProperty] in production.
78+
d._default_properties = [DreameVacuumProperty.BATTERY_LEVEL]
79+
d._map_manager = None
80+
d.available = False
81+
d.info = None
82+
return d
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# Helper: build a property dict
87+
# ---------------------------------------------------------------------------
88+
89+
90+
def _prop(did: int, value: object, siid: int = 0, piid: int = 0, code: int = 0) -> dict:
91+
return {"did": str(did), "siid": siid, "piid": piid, "code": code, "value": value}
92+
93+
94+
# ===========================================================================
95+
# Étape 2 : _handle_properties — détection de changement
96+
# ===========================================================================
97+
98+
99+
class TestHandlePropertiesChangeDetection:
100+
"""_handle_properties core routing: new value, same value, map exclusions."""
101+
102+
def test_new_value_stored_and_changed_returned(self) -> None:
103+
"""New property value is stored; return is truthy (True)."""
104+
d = _bare_device()
105+
result = d._handle_properties([_prop(_BATTERY_DID, 85)])
106+
assert d.data[_BATTERY_DID] == 85
107+
assert result is True
108+
d._property_changed.assert_called_once()
109+
110+
def test_same_value_reemitted_no_change(self) -> None:
111+
"""Re-emitting the same value produces no change."""
112+
d = _bare_device()
113+
d.data[_BATTERY_DID] = 85
114+
result = d._handle_properties([_prop(_BATTERY_DID, 85)])
115+
assert result is False
116+
d._property_changed.assert_not_called()
117+
assert d.data[_BATTERY_DID] == 85
118+
119+
def test_different_value_updates_data_and_signals_change(self) -> None:
120+
"""A changed value updates data and triggers _property_changed."""
121+
d = _bare_device()
122+
d.data[_BATTERY_DID] = 80
123+
result = d._handle_properties([_prop(_BATTERY_DID, 90)])
124+
assert d.data[_BATTERY_DID] == 90
125+
assert result is True
126+
d._property_changed.assert_called_once()
127+
128+
def test_code_nonzero_property_ignored(self) -> None:
129+
"""Props with code != 0 are silently ignored."""
130+
d = _bare_device()
131+
result = d._handle_properties([_prop(_BATTERY_DID, 50, code=1)])
132+
assert _BATTERY_DID not in d.data
133+
assert result is False
134+
d._property_changed.assert_not_called()
135+
136+
def test_non_dict_entry_in_list_ignored(self) -> None:
137+
"""Non-dict entries in the property list must not raise."""
138+
d = _bare_device()
139+
result = d._handle_properties(["not a dict", 42, None, _prop(_BATTERY_DID, 77)]) # type: ignore[list-item]
140+
assert d.data[_BATTERY_DID] == 77
141+
assert result is True
142+
143+
def test_unknown_did_and_did_siid_piid_none_ignored(self) -> None:
144+
"""did not in enum AND DID(siid=0, piid=0)==None → property ignored."""
145+
d = _bare_device()
146+
result = d._handle_properties([{"did": "999999", "siid": 0, "piid": 0, "code": 0, "value": 1}])
147+
assert d.data == {}
148+
assert result is False
149+
150+
def test_map_list_property_data_updated_but_not_changed(self) -> None:
151+
"""MAP_LIST change updates data but does NOT set changed (no _property_changed call)."""
152+
d = _bare_device()
153+
result = d._handle_properties([_prop(_MAP_LIST_DID, "some_map")])
154+
assert d.data[_MAP_LIST_DID] == "some_map"
155+
assert result is False
156+
d._property_changed.assert_not_called()
157+
158+
159+
# ===========================================================================
160+
# Étape 3 : _handle_properties — dirty data
161+
# ===========================================================================
162+
163+
164+
class TestHandlePropertiesDirtyData:
165+
"""Dirty-data discard logic."""
166+
167+
def test_stale_echo_discarded_within_window(self) -> None:
168+
"""Incoming value differs from dirty sentinel within timeout → rejected."""
169+
d = _bare_device()
170+
d._dirty_data[_BATTERY_DID] = DirtyData(value=90, update_time=time.time())
171+
result = d._handle_properties([_prop(_BATTERY_DID, 85)])
172+
# Value 85 must NOT be stored — the echo was discarded
173+
assert _BATTERY_DID not in d.data
174+
# dirty entry removed after discard
175+
assert _BATTERY_DID not in d._dirty_data
176+
assert result is False
177+
178+
def test_matching_value_accepted_dirty_cleared(self) -> None:
179+
"""Incoming value matches dirty sentinel → accepted, dirty cleared."""
180+
d = _bare_device()
181+
d._dirty_data[_BATTERY_DID] = DirtyData(value=85, update_time=time.time())
182+
result = d._handle_properties([_prop(_BATTERY_DID, 85)])
183+
assert d.data[_BATTERY_DID] == 85
184+
assert _BATTERY_DID not in d._dirty_data
185+
# First write → changed
186+
assert result is True
187+
188+
def test_expired_window_accepted(self) -> None:
189+
"""Dirty entry outside discard window → incoming value accepted."""
190+
d = _bare_device()
191+
d._dirty_data[_BATTERY_DID] = DirtyData(value=90, update_time=time.time() - 10)
192+
result = d._handle_properties([_prop(_BATTERY_DID, 85)])
193+
assert d.data[_BATTERY_DID] == 85
194+
assert _BATTERY_DID not in d._dirty_data
195+
assert result is True
196+
197+
def test_update_time_none_treated_as_expired(self) -> None:
198+
"""update_time=None → (None or 0) == 0 → window expired → value accepted."""
199+
d = _bare_device()
200+
d._dirty_data[_BATTERY_DID] = DirtyData(value=90, update_time=None)
201+
result = d._handle_properties([_prop(_BATTERY_DID, 85)])
202+
# time.time() - 0 is a large positive number > _discard_timeout=2 → accepted
203+
assert d.data[_BATTERY_DID] == 85
204+
assert _BATTERY_DID not in d._dirty_data
205+
assert result is True
206+
207+
208+
# ===========================================================================
209+
# Étape 4 : _handle_properties — callbacks
210+
# ===========================================================================
211+
212+
213+
class TestHandlePropertiesCallbacks:
214+
"""Callback dispatch."""
215+
216+
def test_callback_called_with_old_value(self) -> None:
217+
"""Registered callback receives the previous (old) value.
218+
219+
On first insertion d.data has no entry → current_value is None.
220+
"""
221+
d = _bare_device()
222+
cb = MagicMock()
223+
d._property_update_callback[_BATTERY_DID] = [cb]
224+
d._handle_properties([_prop(_BATTERY_DID, 85)])
225+
cb.assert_called_once_with(None)
226+
227+
def test_callback_called_with_previous_value_on_update(self) -> None:
228+
"""On update, callback receives the value that was stored before the change."""
229+
d = _bare_device()
230+
d.data[_BATTERY_DID] = 80
231+
cb = MagicMock()
232+
d._property_update_callback[_BATTERY_DID] = [cb]
233+
d._handle_properties([_prop(_BATTERY_DID, 90)])
234+
cb.assert_called_once_with(80)
235+
236+
def test_callback_not_called_when_no_change(self) -> None:
237+
"""No change → callback not invoked."""
238+
d = _bare_device()
239+
d.data[_BATTERY_DID] = 85
240+
cb = MagicMock()
241+
d._property_update_callback[_BATTERY_DID] = [cb]
242+
d._handle_properties([_prop(_BATTERY_DID, 85)])
243+
cb.assert_not_called()
244+
245+
def test_two_callbacks_both_called(self) -> None:
246+
"""Two callbacks registered for same did → both invoked."""
247+
d = _bare_device()
248+
cb1 = MagicMock()
249+
cb2 = MagicMock()
250+
d._property_update_callback[_BATTERY_DID] = [cb1, cb2]
251+
d._handle_properties([_prop(_BATTERY_DID, 77)])
252+
cb1.assert_called_once_with(None)
253+
cb2.assert_called_once_with(None)
254+
255+
256+
# ===========================================================================
257+
# Étape 5 : _message_callback — routing
258+
# ===========================================================================
259+
260+
261+
class TestMessageCallbackRouting:
262+
"""_message_callback routing and guards."""
263+
264+
def test_not_ready_returns_immediately(self) -> None:
265+
"""_ready=False → early return, nothing mutated."""
266+
d = _bare_device_with_message_support()
267+
d._ready = False
268+
msg = {
269+
"method": "properties_changed",
270+
"params": [{"siid": _BATTERY_SIID, "piid": _BATTERY_PIID, "code": 0, "value": 50}],
271+
}
272+
d._message_callback(msg)
273+
assert d.available is False
274+
assert d.data == {}
275+
276+
def test_properties_changed_updates_data_and_available(self) -> None:
277+
"""properties_changed with known siid/piid → available=True, data updated."""
278+
d = _bare_device_with_message_support()
279+
msg = {
280+
"method": "properties_changed",
281+
"params": [{"siid": _BATTERY_SIID, "piid": _BATTERY_PIID, "code": 0, "value": 72}],
282+
}
283+
d._message_callback(msg)
284+
assert d.available is True
285+
assert d.data[_BATTERY_DID] == 72
286+
287+
def test_map_property_no_map_manager_no_crash(self) -> None:
288+
"""Map property (OBJECT_NAME) with _map_manager=None → no exception."""
289+
d = _bare_device_with_message_support()
290+
# OBJECT_NAME siid=6 piid=3, not in _default_properties → map_properties path
291+
msg = {
292+
"method": "properties_changed",
293+
"params": [{"siid": 6, "piid": 3, "code": 0, "value": "mapdata"}],
294+
}
295+
# Should not raise
296+
d._message_callback(msg)
297+
assert d.available is True
298+
299+
def test_message_without_method_or_params_ignored(self) -> None:
300+
"""Message missing method/params keys → silently ignored."""
301+
d = _bare_device_with_message_support()
302+
d._message_callback({"method": "properties_changed"}) # no params
303+
assert d.available is False
304+
d._message_callback({"params": []}) # no method
305+
assert d.available is False
306+
d._message_callback({})
307+
assert d.available is False

0 commit comments

Comments
 (0)