test_main.py 12 KB

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