test_layer_timelapse.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. """
  2. Tests for the layer timelapse service.
  3. These tests cover session management and pure logic functions.
  4. """
  5. from datetime import datetime
  6. from pathlib import Path
  7. from unittest.mock import AsyncMock, MagicMock, patch
  8. import pytest
  9. class TestTimelapseSessionManagement:
  10. """Tests for timelapse session lifecycle."""
  11. def test_start_session_creates_new_session(self):
  12. """Verify start_session creates and registers a new session."""
  13. from backend.app.services.layer_timelapse import (
  14. _active_sessions,
  15. cancel_session,
  16. get_session,
  17. start_session,
  18. )
  19. # Clear any existing sessions
  20. _active_sessions.clear()
  21. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  22. mock_settings.base_dir = Path("/tmp/test_bambuddy")
  23. session = start_session(
  24. printer_id=1,
  25. archive_id=100,
  26. url="http://camera.local/mjpeg",
  27. cam_type="mjpeg",
  28. )
  29. assert session is not None
  30. assert session.printer_id == 1
  31. assert session.archive_id == 100
  32. assert session.camera_url == "http://camera.local/mjpeg"
  33. assert session.camera_type == "mjpeg"
  34. assert session.last_layer == -1
  35. assert session.frame_count == 0
  36. # Session should be retrievable
  37. retrieved = get_session(1)
  38. assert retrieved is session
  39. # Cleanup
  40. cancel_session(1)
  41. def test_start_session_cancels_existing(self):
  42. """Verify starting a new session cancels any existing session."""
  43. from backend.app.services.layer_timelapse import (
  44. _active_sessions,
  45. cancel_session,
  46. get_session,
  47. start_session,
  48. )
  49. _active_sessions.clear()
  50. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  51. mock_settings.base_dir = Path("/tmp/test_bambuddy")
  52. # Start first session
  53. session1 = start_session(1, 100, "http://cam1/", "mjpeg")
  54. # Mock cleanup to track if it was called
  55. session1.cleanup = MagicMock()
  56. # Start second session for same printer
  57. session2 = start_session(1, 101, "http://cam2/", "rtsp")
  58. # First session should be replaced
  59. current = get_session(1)
  60. assert current is session2
  61. assert current.archive_id == 101 # Verify it's the new session
  62. assert current.camera_url == "http://cam2/"
  63. # First session's cleanup should have been called
  64. session1.cleanup.assert_called_once()
  65. # Cleanup
  66. cancel_session(1)
  67. def test_get_session_returns_none_for_unknown(self):
  68. """Verify get_session returns None for unknown printer."""
  69. from backend.app.services.layer_timelapse import _active_sessions, get_session
  70. _active_sessions.clear()
  71. result = get_session(999)
  72. assert result is None
  73. def test_cancel_session_removes_and_cleans_up(self):
  74. """Verify cancel_session removes session and cleans up."""
  75. from backend.app.services.layer_timelapse import (
  76. _active_sessions,
  77. cancel_session,
  78. get_session,
  79. start_session,
  80. )
  81. _active_sessions.clear()
  82. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  83. mock_settings.base_dir = Path("/tmp/test_bambuddy")
  84. session = start_session(1, 100, "http://cam/", "mjpeg")
  85. # Mock cleanup to avoid filesystem operations
  86. session.cleanup = MagicMock()
  87. cancel_session(1)
  88. # Session should be removed
  89. assert get_session(1) is None
  90. # Cleanup should have been called
  91. session.cleanup.assert_called_once()
  92. def test_cancel_nonexistent_session_is_safe(self):
  93. """Verify canceling a non-existent session doesn't error."""
  94. from backend.app.services.layer_timelapse import _active_sessions, cancel_session
  95. _active_sessions.clear()
  96. # Should not raise
  97. cancel_session(999)
  98. class TestTimelapseSession:
  99. """Tests for TimelapseSession class."""
  100. def test_session_id_format(self):
  101. """Verify session ID follows expected datetime format."""
  102. from backend.app.services.layer_timelapse import TimelapseSession, _active_sessions
  103. _active_sessions.clear()
  104. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  105. mock_settings.base_dir = Path("/tmp/test_bambuddy")
  106. session = TimelapseSession(
  107. printer_id=1,
  108. archive_id=100,
  109. camera_url="http://test/",
  110. camera_type="mjpeg",
  111. )
  112. # Session ID should be timestamp format YYYYMMDD_HHMMSS
  113. assert len(session.session_id) == 15
  114. assert session.session_id[8] == "_"
  115. # Should be parseable as datetime
  116. try:
  117. datetime.strptime(session.session_id, "%Y%m%d_%H%M%S")
  118. except ValueError:
  119. pytest.fail("Session ID is not valid datetime format")
  120. def test_frames_dir_path_structure(self):
  121. """Verify frames directory path is structured correctly."""
  122. from backend.app.services.layer_timelapse import TimelapseSession
  123. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  124. mock_settings.base_dir = Path("/data/bambuddy")
  125. with patch.object(Path, "mkdir"): # Avoid creating real directories
  126. session = TimelapseSession(
  127. printer_id=42,
  128. archive_id=100,
  129. camera_url="http://test/",
  130. camera_type="mjpeg",
  131. )
  132. expected_path = Path("/data/bambuddy/timelapse_frames/42") / session.session_id
  133. assert session.frames_dir == expected_path
  134. class TestLayerChangeLogic:
  135. """Tests for layer change capture logic."""
  136. @pytest.mark.asyncio
  137. async def test_capture_layer_only_on_increase(self):
  138. """Verify frames are only captured when layer increases."""
  139. from backend.app.services.layer_timelapse import TimelapseSession
  140. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  141. mock_settings.base_dir = Path("/tmp/test")
  142. with patch.object(Path, "mkdir"):
  143. session = TimelapseSession(1, 100, "http://test/", "mjpeg")
  144. # Mock capture_frame to return data
  145. with patch(
  146. "backend.app.services.layer_timelapse.capture_frame", new_callable=AsyncMock
  147. ) as mock_capture:
  148. mock_capture.return_value = b"\xff\xd8test\xff\xd9"
  149. with patch.object(Path, "write_bytes"):
  150. # First layer should capture
  151. result = await session.capture_layer(1)
  152. assert result is True
  153. assert session.last_layer == 1
  154. assert session.frame_count == 1
  155. # Same layer should NOT capture
  156. result = await session.capture_layer(1)
  157. assert result is False
  158. assert session.frame_count == 1
  159. # Lower layer should NOT capture
  160. result = await session.capture_layer(0)
  161. assert result is False
  162. assert session.frame_count == 1
  163. # Higher layer should capture
  164. result = await session.capture_layer(5)
  165. assert result is True
  166. assert session.last_layer == 5
  167. assert session.frame_count == 2
  168. @pytest.mark.asyncio
  169. async def test_capture_layer_handles_failed_capture(self):
  170. """Verify failed capture returns False but updates layer."""
  171. from backend.app.services.layer_timelapse import TimelapseSession
  172. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  173. mock_settings.base_dir = Path("/tmp/test")
  174. with patch.object(Path, "mkdir"):
  175. session = TimelapseSession(1, 100, "http://test/", "mjpeg")
  176. # Mock capture_frame to return None (failure)
  177. with patch(
  178. "backend.app.services.layer_timelapse.capture_frame", new_callable=AsyncMock
  179. ) as mock_capture:
  180. mock_capture.return_value = None
  181. result = await session.capture_layer(1)
  182. assert result is False
  183. assert session.last_layer == 1 # Layer is still updated
  184. assert session.frame_count == 0 # But frame count not incremented
  185. class TestOnLayerChange:
  186. """Tests for the on_layer_change callback."""
  187. @pytest.mark.asyncio
  188. async def test_on_layer_change_captures_when_session_exists(self):
  189. """Verify on_layer_change triggers capture when session exists."""
  190. from backend.app.services.layer_timelapse import (
  191. _active_sessions,
  192. cancel_session,
  193. on_layer_change,
  194. start_session,
  195. )
  196. _active_sessions.clear()
  197. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  198. mock_settings.base_dir = Path("/tmp/test")
  199. with patch.object(Path, "mkdir"):
  200. session = start_session(1, 100, "http://test/", "mjpeg")
  201. with patch.object(session, "capture_layer", new_callable=AsyncMock) as mock_capture:
  202. mock_capture.return_value = True
  203. await on_layer_change(1, 5)
  204. mock_capture.assert_called_once_with(5)
  205. cancel_session(1)
  206. @pytest.mark.asyncio
  207. async def test_on_layer_change_does_nothing_without_session(self):
  208. """Verify on_layer_change is safe when no session exists."""
  209. from backend.app.services.layer_timelapse import _active_sessions, on_layer_change
  210. _active_sessions.clear()
  211. # Should not raise
  212. await on_layer_change(999, 10)
  213. class TestGetActiveSessions:
  214. """Tests for get_active_sessions."""
  215. def test_get_active_sessions_returns_copy(self):
  216. """Verify get_active_sessions returns a copy, not the original dict."""
  217. from backend.app.services.layer_timelapse import (
  218. _active_sessions,
  219. cancel_session,
  220. get_active_sessions,
  221. start_session,
  222. )
  223. _active_sessions.clear()
  224. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  225. mock_settings.base_dir = Path("/tmp/test")
  226. with patch.object(Path, "mkdir"):
  227. start_session(1, 100, "http://test/", "mjpeg")
  228. sessions = get_active_sessions()
  229. # Should be a copy
  230. assert sessions is not _active_sessions
  231. assert 1 in sessions
  232. # Modifying copy shouldn't affect original
  233. sessions.clear()
  234. assert 1 in _active_sessions
  235. cancel_session(1)