test_obico_detection.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. """Unit tests for Obico detection service (#172)."""
  2. from unittest.mock import AsyncMock, MagicMock, patch
  3. import pytest
  4. from backend.app.schemas.settings import AppSettingsUpdate
  5. from backend.app.services.obico_detection import ObicoDetectionService
  6. from backend.app.services.obico_smoothing import WARMUP_FRAMES
  7. FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
  8. class TestSettingsSchemaValidators:
  9. """Guard rails on the new obico_* AppSettings fields."""
  10. def test_sensitivity_accepts_valid_values(self):
  11. for value in ("low", "medium", "high"):
  12. u = AppSettingsUpdate(obico_sensitivity=value)
  13. assert u.obico_sensitivity == value
  14. def test_sensitivity_rejects_garbage(self):
  15. with pytest.raises(ValueError, match="obico_sensitivity"):
  16. AppSettingsUpdate(obico_sensitivity="extreme")
  17. def test_action_accepts_valid_values(self):
  18. for value in ("notify", "pause", "pause_and_off"):
  19. assert AppSettingsUpdate(obico_action=value).obico_action == value
  20. def test_action_rejects_garbage(self):
  21. with pytest.raises(ValueError, match="obico_action"):
  22. AppSettingsUpdate(obico_action="explode")
  23. def test_enabled_printers_accepts_empty(self):
  24. assert AppSettingsUpdate(obico_enabled_printers="").obico_enabled_printers == ""
  25. assert AppSettingsUpdate(obico_enabled_printers=None).obico_enabled_printers is None
  26. def test_enabled_printers_accepts_int_array(self):
  27. u = AppSettingsUpdate(obico_enabled_printers="[1, 2, 3]")
  28. assert u.obico_enabled_printers == "[1, 2, 3]"
  29. def test_enabled_printers_rejects_non_json(self):
  30. with pytest.raises(ValueError, match="valid JSON"):
  31. AppSettingsUpdate(obico_enabled_printers="1,2,3")
  32. def test_enabled_printers_rejects_non_list(self):
  33. with pytest.raises(ValueError, match="JSON array"):
  34. AppSettingsUpdate(obico_enabled_printers='{"1": true}')
  35. def test_enabled_printers_rejects_non_int_elements(self):
  36. with pytest.raises(ValueError, match="JSON array"):
  37. AppSettingsUpdate(obico_enabled_printers='[1, "two"]')
  38. def test_poll_interval_bounds(self):
  39. with pytest.raises(ValueError):
  40. AppSettingsUpdate(obico_poll_interval=4)
  41. with pytest.raises(ValueError):
  42. AppSettingsUpdate(obico_poll_interval=121)
  43. assert AppSettingsUpdate(obico_poll_interval=10).obico_poll_interval == 10
  44. class TestGetStatus:
  45. def test_empty_initial_status(self):
  46. svc = ObicoDetectionService()
  47. s = svc.get_status()
  48. assert s["is_running"] is False
  49. assert s["per_printer"] == {}
  50. assert s["history"] == []
  51. assert "low" in s["thresholds"] and "high" in s["thresholds"]
  52. class TestTestConnection:
  53. @pytest.mark.asyncio
  54. async def test_empty_url_via_route(self):
  55. """Service does not special-case empty URL — the route does."""
  56. svc = ObicoDetectionService()
  57. # This will fail DNS/connect, but should return ok=False
  58. result = await svc.test_connection("http://nonexistent-obico-host-xyz.invalid:3333")
  59. assert result["ok"] is False
  60. assert result["error"] is not None
  61. @pytest.mark.asyncio
  62. async def test_healthy_response_is_ok(self):
  63. svc = ObicoDetectionService()
  64. mock_response = MagicMock(status_code=200, text="ok")
  65. mock_client = MagicMock()
  66. mock_client.get = AsyncMock(return_value=mock_response)
  67. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  68. mock_client.__aexit__ = AsyncMock(return_value=False)
  69. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  70. result = await svc.test_connection("http://obico:3333")
  71. assert result["ok"] is True
  72. assert result["status_code"] == 200
  73. assert result["body"] == "ok"
  74. assert result["error"] is None
  75. @pytest.mark.asyncio
  76. async def test_non_ok_body_is_not_ok(self):
  77. svc = ObicoDetectionService()
  78. mock_response = MagicMock(status_code=200, text="something else")
  79. mock_client = MagicMock()
  80. mock_client.get = AsyncMock(return_value=mock_response)
  81. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  82. mock_client.__aexit__ = AsyncMock(return_value=False)
  83. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  84. result = await svc.test_connection("http://obico:3333/")
  85. assert result["ok"] is False
  86. assert result["body"] == "something else"
  87. class TestPollOneStateLifecycle:
  88. """Confirms per-printer state is reset when a new print starts."""
  89. @pytest.mark.asyncio
  90. async def test_new_task_name_resets_state(self):
  91. svc = ObicoDetectionService()
  92. # Seed a state that has been running for a while
  93. from backend.app.services.obico_smoothing import PrintState
  94. seeded = PrintState()
  95. for _ in range(WARMUP_FRAMES + 5):
  96. seeded.update(0.5)
  97. svc._states[1] = seeded
  98. svc._state_keys[1] = "old_task"
  99. svc._action_fired[1] = True
  100. settings = {
  101. "enabled": True,
  102. "ml_url": "http://obico:3333",
  103. "sensitivity": "medium",
  104. "action": "notify",
  105. "poll_interval": 10,
  106. "enabled_printers": None,
  107. }
  108. status = MagicMock(state="RUNNING", task_name="new_task", subtask_name="")
  109. mock_response = MagicMock()
  110. mock_response.json.return_value = {"detections": []}
  111. mock_response.raise_for_status = MagicMock()
  112. mock_client = MagicMock()
  113. mock_client.post = AsyncMock(return_value=mock_response)
  114. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  115. mock_client.__aexit__ = AsyncMock(return_value=False)
  116. with (
  117. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  118. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  119. ):
  120. await svc._check_printer(1, status, settings)
  121. # State was reset (frame_count is 1 after the single update, not 36)
  122. assert svc._states[1].frame_count == 1
  123. assert svc._state_keys[1] == "new_task"
  124. assert svc._action_fired[1] is False
  125. @pytest.mark.asyncio
  126. async def test_ml_api_error_does_not_crash(self):
  127. svc = ObicoDetectionService()
  128. settings = {
  129. "enabled": True,
  130. "ml_url": "http://obico:3333",
  131. "sensitivity": "medium",
  132. "action": "notify",
  133. "poll_interval": 10,
  134. "enabled_printers": None,
  135. }
  136. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  137. mock_client = MagicMock()
  138. mock_client.post = AsyncMock(side_effect=RuntimeError("connection refused"))
  139. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  140. mock_client.__aexit__ = AsyncMock(return_value=False)
  141. with (
  142. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  143. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  144. ):
  145. await svc._check_printer(1, status, settings)
  146. assert svc._last_error is not None
  147. assert "connection refused" in svc._last_error
  148. @pytest.mark.asyncio
  149. async def test_failure_fires_action_only_once(self):
  150. """Once a failure has fired for a print, subsequent failures should not re-fire."""
  151. svc = ObicoDetectionService()
  152. settings = {
  153. "enabled": True,
  154. "ml_url": "http://obico:3333",
  155. "sensitivity": "medium",
  156. "action": "notify",
  157. "poll_interval": 10,
  158. "enabled_printers": None,
  159. }
  160. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  161. # Seed state so the next frame crosses HIGH immediately
  162. from backend.app.services.obico_smoothing import PrintState
  163. seeded = PrintState()
  164. for _ in range(WARMUP_FRAMES + 500):
  165. seeded.update(1.0)
  166. svc._states[1] = seeded
  167. svc._state_keys[1] = "job"
  168. svc._action_fired[1] = False
  169. mock_response = MagicMock()
  170. mock_response.json.return_value = {"detections": [["failure", 0.9, [0, 0, 1, 1]]]}
  171. mock_response.raise_for_status = MagicMock()
  172. mock_client = MagicMock()
  173. mock_client.post = AsyncMock(return_value=mock_response)
  174. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  175. mock_client.__aexit__ = AsyncMock(return_value=False)
  176. with (
  177. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  178. patch("backend.app.services.obico_actions.execute_action", new=AsyncMock()) as mock_action,
  179. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  180. ):
  181. await svc._check_printer(1, status, settings)
  182. assert mock_action.call_count == 1
  183. await svc._check_printer(1, status, settings)
  184. # Second call must not dispatch again
  185. assert mock_action.call_count == 1
  186. class TestCheckPrinterPostsImageDirectly:
  187. """The detection loop must POST JPEG bytes directly to the ML API."""
  188. @pytest.mark.asyncio
  189. async def test_ml_api_called_with_post_and_image_bytes(self):
  190. svc = ObicoDetectionService()
  191. settings = {
  192. "enabled": True,
  193. "ml_url": "http://obico:3333",
  194. "sensitivity": "medium",
  195. "action": "notify",
  196. "poll_interval": 10,
  197. "enabled_printers": None,
  198. }
  199. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  200. mock_response = MagicMock()
  201. mock_response.json.return_value = {"detections": []}
  202. mock_response.raise_for_status = MagicMock()
  203. mock_client = MagicMock()
  204. mock_client.post = AsyncMock(return_value=mock_response)
  205. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  206. mock_client.__aexit__ = AsyncMock(return_value=False)
  207. with (
  208. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  209. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  210. ):
  211. await svc._check_printer(1, status, settings)
  212. # ML API was called via POST
  213. mock_client.post.assert_called_once()
  214. _args, kwargs = mock_client.post.call_args
  215. # URL is the ML API /p/ endpoint
  216. assert _args[0] == "http://obico:3333/p/"
  217. # Image bytes sent as multipart file upload
  218. files = kwargs["files"]
  219. assert "img" in files
  220. filename, data, content_type = files["img"]
  221. assert filename == "snapshot.jpg"
  222. assert data == FAKE_JPEG
  223. assert content_type == "image/jpeg"
  224. @pytest.mark.asyncio
  225. async def test_capture_failure_skips_ml_call(self):
  226. """If we can't capture a frame, don't bother the ML API."""
  227. svc = ObicoDetectionService()
  228. settings = {
  229. "enabled": True,
  230. "ml_url": "http://obico:3333",
  231. "sensitivity": "medium",
  232. "action": "notify",
  233. "poll_interval": 10,
  234. "enabled_printers": None,
  235. }
  236. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  237. mock_client = MagicMock()
  238. mock_client.post = AsyncMock()
  239. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  240. mock_client.__aexit__ = AsyncMock(return_value=False)
  241. with (
  242. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  243. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=None)),
  244. ):
  245. await svc._check_printer(1, status, settings)
  246. mock_client.post.assert_not_called()
  247. assert svc._last_error is not None
  248. assert "Failed to capture snapshot" in svc._last_error