test_obico_detection.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  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. class TestSettingsSchemaValidators:
  8. """Guard rails on the new obico_* AppSettings fields."""
  9. def test_sensitivity_accepts_valid_values(self):
  10. for value in ("low", "medium", "high"):
  11. u = AppSettingsUpdate(obico_sensitivity=value)
  12. assert u.obico_sensitivity == value
  13. def test_sensitivity_rejects_garbage(self):
  14. with pytest.raises(ValueError, match="obico_sensitivity"):
  15. AppSettingsUpdate(obico_sensitivity="extreme")
  16. def test_action_accepts_valid_values(self):
  17. for value in ("notify", "pause", "pause_and_off"):
  18. assert AppSettingsUpdate(obico_action=value).obico_action == value
  19. def test_action_rejects_garbage(self):
  20. with pytest.raises(ValueError, match="obico_action"):
  21. AppSettingsUpdate(obico_action="explode")
  22. def test_enabled_printers_accepts_empty(self):
  23. assert AppSettingsUpdate(obico_enabled_printers="").obico_enabled_printers == ""
  24. assert AppSettingsUpdate(obico_enabled_printers=None).obico_enabled_printers is None
  25. def test_enabled_printers_accepts_int_array(self):
  26. u = AppSettingsUpdate(obico_enabled_printers="[1, 2, 3]")
  27. assert u.obico_enabled_printers == "[1, 2, 3]"
  28. def test_enabled_printers_rejects_non_json(self):
  29. with pytest.raises(ValueError, match="valid JSON"):
  30. AppSettingsUpdate(obico_enabled_printers="1,2,3")
  31. def test_enabled_printers_rejects_non_list(self):
  32. with pytest.raises(ValueError, match="JSON array"):
  33. AppSettingsUpdate(obico_enabled_printers='{"1": true}')
  34. def test_enabled_printers_rejects_non_int_elements(self):
  35. with pytest.raises(ValueError, match="JSON array"):
  36. AppSettingsUpdate(obico_enabled_printers='[1, "two"]')
  37. def test_poll_interval_bounds(self):
  38. with pytest.raises(ValueError):
  39. AppSettingsUpdate(obico_poll_interval=4)
  40. with pytest.raises(ValueError):
  41. AppSettingsUpdate(obico_poll_interval=121)
  42. assert AppSettingsUpdate(obico_poll_interval=10).obico_poll_interval == 10
  43. class TestGetStatus:
  44. def test_empty_initial_status(self):
  45. svc = ObicoDetectionService()
  46. s = svc.get_status()
  47. assert s["is_running"] is False
  48. assert s["per_printer"] == {}
  49. assert s["history"] == []
  50. assert "low" in s["thresholds"] and "high" in s["thresholds"]
  51. class TestTestConnection:
  52. @pytest.mark.asyncio
  53. async def test_empty_url_via_route(self):
  54. """Service does not special-case empty URL — the route does."""
  55. svc = ObicoDetectionService()
  56. # This will fail DNS/connect, but should return ok=False
  57. result = await svc.test_connection("http://nonexistent-obico-host-xyz.invalid:3333")
  58. assert result["ok"] is False
  59. assert result["error"] is not None
  60. @pytest.mark.asyncio
  61. async def test_healthy_response_is_ok(self):
  62. svc = ObicoDetectionService()
  63. mock_response = MagicMock(status_code=200, text="ok")
  64. mock_client = MagicMock()
  65. mock_client.get = AsyncMock(return_value=mock_response)
  66. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  67. mock_client.__aexit__ = AsyncMock(return_value=False)
  68. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  69. result = await svc.test_connection("http://obico:3333")
  70. assert result["ok"] is True
  71. assert result["status_code"] == 200
  72. assert result["body"] == "ok"
  73. assert result["error"] is None
  74. @pytest.mark.asyncio
  75. async def test_non_ok_body_is_not_ok(self):
  76. svc = ObicoDetectionService()
  77. mock_response = MagicMock(status_code=200, text="something else")
  78. mock_client = MagicMock()
  79. mock_client.get = AsyncMock(return_value=mock_response)
  80. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  81. mock_client.__aexit__ = AsyncMock(return_value=False)
  82. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  83. result = await svc.test_connection("http://obico:3333/")
  84. assert result["ok"] is False
  85. assert result["body"] == "something else"
  86. class TestPollOneStateLifecycle:
  87. """Confirms per-printer state is reset when a new print starts."""
  88. @pytest.mark.asyncio
  89. async def test_new_task_name_resets_state(self):
  90. svc = ObicoDetectionService()
  91. # Seed a state that has been running for a while
  92. from backend.app.services.obico_smoothing import PrintState
  93. seeded = PrintState()
  94. for _ in range(WARMUP_FRAMES + 5):
  95. seeded.update(0.5)
  96. svc._states[1] = seeded
  97. svc._state_keys[1] = "old_task"
  98. svc._action_fired[1] = True
  99. settings = {
  100. "enabled": True,
  101. "ml_url": "http://obico:3333",
  102. "sensitivity": "medium",
  103. "action": "notify",
  104. "poll_interval": 10,
  105. "enabled_printers": None,
  106. "external_url": "http://bambuddy:8000",
  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.get = 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(
  119. "backend.app.services.obico_detection.create_camera_stream_token",
  120. new=AsyncMock(return_value="tok"),
  121. ),
  122. ):
  123. await svc._check_printer(1, status, settings)
  124. # State was reset (frame_count is 1 after the single update, not 36)
  125. assert svc._states[1].frame_count == 1
  126. assert svc._state_keys[1] == "new_task"
  127. assert svc._action_fired[1] is False
  128. @pytest.mark.asyncio
  129. async def test_ml_api_error_does_not_crash(self):
  130. svc = ObicoDetectionService()
  131. settings = {
  132. "enabled": True,
  133. "ml_url": "http://obico:3333",
  134. "sensitivity": "medium",
  135. "action": "notify",
  136. "poll_interval": 10,
  137. "enabled_printers": None,
  138. "external_url": "http://bambuddy:8000",
  139. }
  140. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  141. mock_client = MagicMock()
  142. mock_client.get = AsyncMock(side_effect=RuntimeError("connection refused"))
  143. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  144. mock_client.__aexit__ = AsyncMock(return_value=False)
  145. with (
  146. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  147. patch(
  148. "backend.app.services.obico_detection.create_camera_stream_token",
  149. new=AsyncMock(return_value="tok"),
  150. ),
  151. ):
  152. await svc._check_printer(1, status, settings)
  153. assert svc._last_error is not None
  154. assert "connection refused" in svc._last_error
  155. @pytest.mark.asyncio
  156. async def test_failure_fires_action_only_once(self):
  157. """Once a failure has fired for a print, subsequent failures should not re-fire."""
  158. svc = ObicoDetectionService()
  159. settings = {
  160. "enabled": True,
  161. "ml_url": "http://obico:3333",
  162. "sensitivity": "medium",
  163. "action": "notify",
  164. "poll_interval": 10,
  165. "enabled_printers": None,
  166. "external_url": "http://bambuddy:8000",
  167. }
  168. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  169. # Seed state so the next frame crosses HIGH immediately
  170. from backend.app.services.obico_smoothing import PrintState
  171. seeded = PrintState()
  172. for _ in range(WARMUP_FRAMES + 500):
  173. seeded.update(1.0)
  174. svc._states[1] = seeded
  175. svc._state_keys[1] = "job"
  176. svc._action_fired[1] = False
  177. mock_response = MagicMock()
  178. mock_response.json.return_value = {"detections": [["failure", 0.9, [0, 0, 1, 1]]]}
  179. mock_response.raise_for_status = MagicMock()
  180. mock_client = MagicMock()
  181. mock_client.get = AsyncMock(return_value=mock_response)
  182. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  183. mock_client.__aexit__ = AsyncMock(return_value=False)
  184. with (
  185. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  186. patch("backend.app.services.obico_actions.execute_action", new=AsyncMock()) as mock_action,
  187. patch(
  188. "backend.app.services.obico_detection.create_camera_stream_token",
  189. new=AsyncMock(return_value="tok"),
  190. ),
  191. ):
  192. await svc._check_printer(1, status, settings)
  193. assert mock_action.call_count == 1
  194. await svc._check_printer(1, status, settings)
  195. # Second call must not dispatch again
  196. assert mock_action.call_count == 1