test_main.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. """Tests for daemon.main — heartbeat_loop command dispatch and scale wake gating."""
  2. import asyncio
  3. import time
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import pytest
  6. from daemon.config import Config
  7. from daemon.main import heartbeat_loop, scale_poll_loop
  8. def _make_config(**overrides):
  9. cfg = Config(
  10. backend_url="http://localhost:5000",
  11. api_key="test-key",
  12. device_id="dev-1",
  13. hostname="test-host",
  14. heartbeat_interval=0.01, # fast for tests
  15. )
  16. for k, v in overrides.items():
  17. setattr(cfg, k, v)
  18. return cfg
  19. def _make_api():
  20. api = AsyncMock()
  21. api.report_update_status = AsyncMock(return_value={"ok": True})
  22. api.heartbeat = AsyncMock(return_value=None)
  23. api.update_tare = AsyncMock(return_value={"ok": True})
  24. return api
  25. class TestHeartbeatLoopCommands:
  26. """Test command dispatch in heartbeat_loop."""
  27. @pytest.mark.asyncio
  28. async def test_tare_command_executes_scale_tare(self):
  29. config = _make_config()
  30. api = _make_api()
  31. call_count = 0
  32. async def mock_heartbeat(*args, **kwargs):
  33. nonlocal call_count
  34. call_count += 1
  35. if call_count == 1:
  36. return {"pending_command": "tare"}
  37. return None
  38. api.heartbeat = mock_heartbeat
  39. scale = MagicMock()
  40. scale.ok = True
  41. scale.tare = MagicMock(return_value=512)
  42. display = MagicMock()
  43. display.tick = MagicMock()
  44. shared = {"nfc": None, "scale": scale, "display": display}
  45. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  46. await asyncio.sleep(0.1)
  47. task.cancel()
  48. try:
  49. await task
  50. except asyncio.CancelledError:
  51. pass
  52. scale.tare.assert_called_once()
  53. api.update_tare.assert_awaited_once_with("dev-1", 512)
  54. assert config.tare_offset == 512
  55. @pytest.mark.asyncio
  56. async def test_tare_command_no_scale_logs_warning(self):
  57. config = _make_config()
  58. api = _make_api()
  59. call_count = 0
  60. async def mock_heartbeat(*args, **kwargs):
  61. nonlocal call_count
  62. call_count += 1
  63. if call_count == 1:
  64. return {"pending_command": "tare"}
  65. return None
  66. api.heartbeat = mock_heartbeat
  67. display = MagicMock()
  68. display.tick = MagicMock()
  69. shared = {"nfc": None, "scale": None, "display": display}
  70. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  71. await asyncio.sleep(0.1)
  72. task.cancel()
  73. try:
  74. await task
  75. except asyncio.CancelledError:
  76. pass
  77. # Should not crash; update_tare should NOT be called
  78. api.update_tare.assert_not_awaited()
  79. @pytest.mark.asyncio
  80. async def test_write_tag_command_sets_pending_write(self):
  81. config = _make_config()
  82. api = _make_api()
  83. call_count = 0
  84. async def mock_heartbeat(*args, **kwargs):
  85. nonlocal call_count
  86. call_count += 1
  87. if call_count == 1:
  88. return {
  89. "pending_command": "write_tag",
  90. "pending_write_payload": {
  91. "spool_id": 42,
  92. "ndef_data_hex": "DEADBEEF",
  93. },
  94. }
  95. return None
  96. api.heartbeat = mock_heartbeat
  97. display = MagicMock()
  98. display.tick = MagicMock()
  99. display.set_brightness = MagicMock()
  100. display.set_blank_timeout = MagicMock()
  101. shared = {"nfc": None, "scale": None, "display": display}
  102. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  103. await asyncio.sleep(0.1)
  104. task.cancel()
  105. try:
  106. await task
  107. except asyncio.CancelledError:
  108. pass
  109. assert "pending_write" in shared
  110. assert shared["pending_write"]["spool_id"] == 42
  111. assert shared["pending_write"]["ndef_data"] == bytes.fromhex("DEADBEEF")
  112. @pytest.mark.asyncio
  113. async def test_display_settings_applied_from_heartbeat(self):
  114. config = _make_config()
  115. api = _make_api()
  116. call_count = 0
  117. async def mock_heartbeat(*args, **kwargs):
  118. nonlocal call_count
  119. call_count += 1
  120. if call_count == 1:
  121. return {
  122. "display_brightness": 75,
  123. "display_blank_timeout": 300,
  124. }
  125. return None
  126. api.heartbeat = mock_heartbeat
  127. display = MagicMock()
  128. display.tick = MagicMock()
  129. shared = {"nfc": None, "scale": None, "display": display}
  130. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  131. await asyncio.sleep(0.1)
  132. task.cancel()
  133. try:
  134. await task
  135. except asyncio.CancelledError:
  136. pass
  137. display.set_brightness.assert_called_with(75)
  138. display.set_blank_timeout.assert_called_with(300)
  139. @pytest.mark.asyncio
  140. async def test_calibration_sync_from_heartbeat(self):
  141. config = _make_config(tare_offset=0, calibration_factor=1.0)
  142. api = _make_api()
  143. call_count = 0
  144. async def mock_heartbeat(*args, **kwargs):
  145. nonlocal call_count
  146. call_count += 1
  147. if call_count == 1:
  148. return {
  149. "tare_offset": 200,
  150. "calibration_factor": 1.05,
  151. }
  152. return None
  153. api.heartbeat = mock_heartbeat
  154. scale = MagicMock()
  155. scale.ok = True
  156. display = MagicMock()
  157. display.tick = MagicMock()
  158. shared = {"nfc": None, "scale": scale, "display": display}
  159. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  160. await asyncio.sleep(0.1)
  161. task.cancel()
  162. try:
  163. await task
  164. except asyncio.CancelledError:
  165. pass
  166. assert config.tare_offset == 200
  167. assert config.calibration_factor == 1.05
  168. scale.update_calibration.assert_called_with(200, 1.05)
  169. class TestScalePollLoopWakeGating:
  170. """Regression tests for the wake-from-scale-noise bug.
  171. A noisy load cell that bounces by ≥50g around its midpoint used to fire
  172. display.wake() on every bounce because the threshold check ran against
  173. `last_wake_grams` which itself advanced to noisy values. The fix gates
  174. wake on the scale's `stable` flag so noise can't trigger wake AND
  175. last_wake_grams only advances to settled readings.
  176. """
  177. @staticmethod
  178. def _make_scale(readings):
  179. """Build a scale mock whose .read() yields the given readings then None forever."""
  180. scale = MagicMock()
  181. scale.ok = True
  182. seq = list(readings)
  183. def _read():
  184. if seq:
  185. return seq.pop(0)
  186. return None
  187. scale.read = _read
  188. return scale
  189. @staticmethod
  190. async def _run_loop(scale, display, *, iterations: int):
  191. config = _make_config(scale_read_interval=0.0, scale_report_interval=0.0)
  192. api = AsyncMock()
  193. api.scale_reading = AsyncMock(return_value=None)
  194. shared = {"scale": scale, "display": display}
  195. # Bypass the real threadpool — call scale.read inline so each loop
  196. # iteration consumes exactly one canned reading without races.
  197. async def _inline_to_thread(fn, *args, **kwargs):
  198. return fn(*args, **kwargs)
  199. with patch("daemon.main.asyncio.to_thread", _inline_to_thread):
  200. task = asyncio.create_task(scale_poll_loop(config, api, shared))
  201. for _ in range(iterations):
  202. await asyncio.sleep(0)
  203. task.cancel()
  204. try:
  205. await task
  206. except asyncio.CancelledError:
  207. pass
  208. @pytest.mark.asyncio
  209. async def test_unstable_noise_above_threshold_does_not_wake(self):
  210. """A noisy load cell bouncing ±60g must NOT fire wake repeatedly."""
  211. # All readings are unstable (stable=False) — typical of an unsettled
  212. # load cell. Each crosses the 50g threshold from the previous value.
  213. readings = [
  214. (100.0, False, 1000),
  215. (160.0, False, 1100), # +60g, unstable
  216. (95.0, False, 990), # -65g, unstable
  217. (155.0, False, 1080), # +60g, unstable
  218. (90.0, False, 970), # -65g, unstable
  219. ]
  220. scale = self._make_scale(readings)
  221. display = MagicMock()
  222. await self._run_loop(scale, display, iterations=20)
  223. display.wake.assert_not_called()
  224. @pytest.mark.asyncio
  225. async def test_stable_large_change_wakes(self):
  226. """A real spool placement (settled reading >50g from baseline) wakes."""
  227. # First a settled baseline at 0g, then a settled new reading at 250g.
  228. readings = [
  229. (0.0, True, 100), # baseline stable
  230. (250.0, True, 5000), # spool placed, settled
  231. ]
  232. scale = self._make_scale(readings)
  233. display = MagicMock()
  234. await self._run_loop(scale, display, iterations=20)
  235. # Wake should fire exactly twice: once on first stable reading
  236. # (last_wake_grams was None) and once on the >50g stable change.
  237. assert display.wake.call_count == 2
  238. @pytest.mark.asyncio
  239. async def test_noise_then_settled_wakes_once(self):
  240. """Noise that briefly exceeds threshold must not bump last_wake_grams.
  241. After noise stops and the scale settles at the original baseline,
  242. the next stable reading at a real new value (>50g away) should still
  243. wake — proving last_wake_grams wasn't poisoned by the noise.
  244. """
  245. readings = [
  246. (0.0, True, 100), # initial settled — first wake (None → 0)
  247. (75.0, False, 1500), # noise spike, unstable, ignored
  248. (-50.0, False, -800), # noise dip, unstable, ignored
  249. (80.0, False, 1600), # noise spike, unstable, ignored
  250. (200.0, True, 4000), # spool placed, settled — should wake (>50g from 0)
  251. ]
  252. scale = self._make_scale(readings)
  253. display = MagicMock()
  254. await self._run_loop(scale, display, iterations=30)
  255. # Two stable wake events: initial baseline + real spool placement.
  256. # If last_wake_grams had advanced to 80 during noise, the 200g jump
  257. # would still wake (delta 120 > 50), so this asserts both gating AND
  258. # the absence of poisoning.
  259. assert display.wake.call_count == 2