test_layer_timelapse.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. """
  2. Tests for the layer timelapse service.
  3. These tests cover session management and pure logic functions.
  4. """
  5. import time
  6. from datetime import datetime
  7. from pathlib import Path
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. class TestTimelapseSessionManagement:
  11. """Tests for timelapse session lifecycle."""
  12. def test_start_session_creates_new_session(self):
  13. """Verify start_session creates and registers a new session."""
  14. from backend.app.services.layer_timelapse import (
  15. _active_sessions,
  16. cancel_session,
  17. get_session,
  18. start_session,
  19. )
  20. # Clear any existing sessions
  21. _active_sessions.clear()
  22. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  23. mock_settings.base_dir = Path("/tmp/test_bambuddy")
  24. session = start_session(
  25. printer_id=1,
  26. archive_id=100,
  27. url="http://camera.local/mjpeg",
  28. cam_type="mjpeg",
  29. )
  30. assert session is not None
  31. assert session.printer_id == 1
  32. assert session.archive_id == 100
  33. assert session.camera_url == "http://camera.local/mjpeg"
  34. assert session.camera_type == "mjpeg"
  35. assert session.last_layer == -1
  36. assert session.frame_count == 0
  37. # Session should be retrievable
  38. retrieved = get_session(1)
  39. assert retrieved is session
  40. # Cleanup
  41. cancel_session(1)
  42. def test_start_session_cancels_existing(self):
  43. """Verify starting a new session cancels any existing session."""
  44. from backend.app.services.layer_timelapse import (
  45. _active_sessions,
  46. cancel_session,
  47. get_session,
  48. start_session,
  49. )
  50. _active_sessions.clear()
  51. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  52. mock_settings.base_dir = Path("/tmp/test_bambuddy")
  53. # Start first session
  54. session1 = start_session(1, 100, "http://cam1/", "mjpeg")
  55. # Mock cleanup to track if it was called
  56. session1.cleanup = MagicMock()
  57. # Start second session for same printer
  58. session2 = start_session(1, 101, "http://cam2/", "rtsp")
  59. # First session should be replaced
  60. current = get_session(1)
  61. assert current is session2
  62. assert current.archive_id == 101 # Verify it's the new session
  63. assert current.camera_url == "http://cam2/"
  64. # First session's cleanup should have been called
  65. session1.cleanup.assert_called_once()
  66. # Cleanup
  67. cancel_session(1)
  68. def test_get_session_returns_none_for_unknown(self):
  69. """Verify get_session returns None for unknown printer."""
  70. from backend.app.services.layer_timelapse import _active_sessions, get_session
  71. _active_sessions.clear()
  72. result = get_session(999)
  73. assert result is None
  74. def test_cancel_session_removes_and_cleans_up(self):
  75. """Verify cancel_session removes session and cleans up."""
  76. from backend.app.services.layer_timelapse import (
  77. _active_sessions,
  78. cancel_session,
  79. get_session,
  80. start_session,
  81. )
  82. _active_sessions.clear()
  83. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  84. mock_settings.base_dir = Path("/tmp/test_bambuddy")
  85. session = start_session(1, 100, "http://cam/", "mjpeg")
  86. # Mock cleanup to avoid filesystem operations
  87. session.cleanup = MagicMock()
  88. cancel_session(1)
  89. # Session should be removed
  90. assert get_session(1) is None
  91. # Cleanup should have been called
  92. session.cleanup.assert_called_once()
  93. def test_cancel_nonexistent_session_is_safe(self):
  94. """Verify canceling a non-existent session doesn't error."""
  95. from backend.app.services.layer_timelapse import _active_sessions, cancel_session
  96. _active_sessions.clear()
  97. # Should not raise
  98. cancel_session(999)
  99. class TestTimelapseSession:
  100. """Tests for TimelapseSession class."""
  101. def test_session_id_format(self):
  102. """Verify session ID follows expected datetime format."""
  103. from backend.app.services.layer_timelapse import TimelapseSession, _active_sessions
  104. _active_sessions.clear()
  105. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  106. mock_settings.base_dir = Path("/tmp/test_bambuddy")
  107. session = TimelapseSession(
  108. printer_id=1,
  109. archive_id=100,
  110. camera_url="http://test/",
  111. camera_type="mjpeg",
  112. )
  113. # Session ID should be timestamp format YYYYMMDD_HHMMSS
  114. assert len(session.session_id) == 15
  115. assert session.session_id[8] == "_"
  116. # Should be parseable as datetime
  117. try:
  118. datetime.strptime(session.session_id, "%Y%m%d_%H%M%S")
  119. except ValueError:
  120. pytest.fail("Session ID is not valid datetime format")
  121. def test_frames_dir_path_structure(self):
  122. """Verify frames directory path is structured correctly."""
  123. from backend.app.services.layer_timelapse import TimelapseSession
  124. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  125. mock_settings.base_dir = Path("/data/bambuddy")
  126. with patch.object(Path, "mkdir"): # Avoid creating real directories
  127. session = TimelapseSession(
  128. printer_id=42,
  129. archive_id=100,
  130. camera_url="http://test/",
  131. camera_type="mjpeg",
  132. )
  133. expected_path = Path("/data/bambuddy/timelapse_frames/42") / session.session_id
  134. assert session.frames_dir == expected_path
  135. class TestLayerChangeLogic:
  136. """Tests for layer change capture logic."""
  137. @pytest.mark.asyncio
  138. async def test_capture_layer_only_on_increase(self):
  139. """Verify frames are only captured when layer increases."""
  140. from backend.app.services.layer_timelapse import TimelapseSession
  141. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  142. mock_settings.base_dir = Path("/tmp/test")
  143. with patch.object(Path, "mkdir"):
  144. session = TimelapseSession(1, 100, "http://test/", "mjpeg")
  145. # Mock capture_frame to return data
  146. with patch(
  147. "backend.app.services.layer_timelapse.capture_frame", new_callable=AsyncMock
  148. ) as mock_capture:
  149. mock_capture.return_value = b"\xff\xd8test\xff\xd9"
  150. with patch.object(Path, "write_bytes"):
  151. # First layer should capture
  152. result = await session.capture_layer(1)
  153. assert result is True
  154. assert session.last_layer == 1
  155. assert session.frame_count == 1
  156. # Same layer should NOT capture
  157. result = await session.capture_layer(1)
  158. assert result is False
  159. assert session.frame_count == 1
  160. # Lower layer should NOT capture
  161. result = await session.capture_layer(0)
  162. assert result is False
  163. assert session.frame_count == 1
  164. # Higher layer should capture
  165. result = await session.capture_layer(5)
  166. assert result is True
  167. assert session.last_layer == 5
  168. assert session.frame_count == 2
  169. @pytest.mark.asyncio
  170. async def test_capture_layer_handles_failed_capture(self):
  171. """Verify failed capture returns False but updates layer."""
  172. from backend.app.services.layer_timelapse import TimelapseSession
  173. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  174. mock_settings.base_dir = Path("/tmp/test")
  175. with patch.object(Path, "mkdir"):
  176. session = TimelapseSession(1, 100, "http://test/", "mjpeg")
  177. # Mock capture_frame to return None (failure)
  178. with patch(
  179. "backend.app.services.layer_timelapse.capture_frame", new_callable=AsyncMock
  180. ) as mock_capture:
  181. mock_capture.return_value = None
  182. result = await session.capture_layer(1)
  183. assert result is False
  184. assert session.last_layer == 1 # Layer is still updated
  185. assert session.frame_count == 0 # But frame count not incremented
  186. class TestOnLayerChange:
  187. """Tests for the on_layer_change callback."""
  188. @pytest.mark.asyncio
  189. async def test_on_layer_change_captures_when_session_exists(self):
  190. """Verify on_layer_change triggers capture when session exists."""
  191. from backend.app.services.layer_timelapse import (
  192. _active_sessions,
  193. cancel_session,
  194. on_layer_change,
  195. start_session,
  196. )
  197. _active_sessions.clear()
  198. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  199. mock_settings.base_dir = Path("/tmp/test")
  200. with patch.object(Path, "mkdir"):
  201. session = start_session(1, 100, "http://test/", "mjpeg")
  202. with patch.object(session, "capture_layer", new_callable=AsyncMock) as mock_capture:
  203. mock_capture.return_value = True
  204. await on_layer_change(1, 5)
  205. mock_capture.assert_called_once_with(5)
  206. cancel_session(1)
  207. @pytest.mark.asyncio
  208. async def test_on_layer_change_does_nothing_without_session(self):
  209. """Verify on_layer_change is safe when no session exists."""
  210. from backend.app.services.layer_timelapse import _active_sessions, on_layer_change
  211. _active_sessions.clear()
  212. # Should not raise
  213. await on_layer_change(999, 10)
  214. class TestGetActiveSessions:
  215. """Tests for get_active_sessions."""
  216. def test_get_active_sessions_returns_copy(self):
  217. """Verify get_active_sessions returns a copy, not the original dict."""
  218. from backend.app.services.layer_timelapse import (
  219. _active_sessions,
  220. cancel_session,
  221. get_active_sessions,
  222. start_session,
  223. )
  224. _active_sessions.clear()
  225. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  226. mock_settings.base_dir = Path("/tmp/test")
  227. with patch.object(Path, "mkdir"):
  228. start_session(1, 100, "http://test/", "mjpeg")
  229. sessions = get_active_sessions()
  230. # Should be a copy
  231. assert sessions is not _active_sessions
  232. assert 1 in sessions
  233. # Modifying copy shouldn't affect original
  234. sessions.clear()
  235. assert 1 in _active_sessions
  236. cancel_session(1)
  237. class TestCleanupOrphanedTimelapseSessions:
  238. """_active_sessions is in-memory only, so a process restart mid-print
  239. loses track of an active session without ever cleaning up its frames
  240. directory (or a stitched-but-not-attached output .mp4). Confirmed live:
  241. 38MB of exactly this leftover on Carl's OrangePi after several restarts
  242. during testing. cleanup_orphaned_timelapse_sessions() sweeps for it."""
  243. def _touch_old(self, path, age_seconds=600):
  244. import os
  245. path.touch()
  246. old = time.time() - age_seconds
  247. os.utime(path, (old, old))
  248. def _mkdir_old(self, path, age_seconds=600):
  249. import os
  250. path.mkdir(parents=True)
  251. old = time.time() - age_seconds
  252. os.utime(path, (old, old))
  253. def test_removes_orphaned_frame_dir_and_stray_output(self, tmp_path):
  254. from backend.app.services.layer_timelapse import (
  255. _active_sessions,
  256. cleanup_orphaned_timelapse_sessions,
  257. )
  258. _active_sessions.clear()
  259. printer_dir = tmp_path / "timelapse_frames" / "1"
  260. self._mkdir_old(printer_dir / "20260101_000000")
  261. self._touch_old(printer_dir / "timelapse_20260101_000000.mp4")
  262. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  263. mock_settings.base_dir = tmp_path
  264. removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
  265. assert removed == 2
  266. assert not (printer_dir / "20260101_000000").exists()
  267. assert not (printer_dir / "timelapse_20260101_000000.mp4").exists()
  268. def test_spares_the_currently_active_session(self, tmp_path):
  269. from backend.app.services.layer_timelapse import (
  270. TimelapseSession,
  271. _active_sessions,
  272. cleanup_orphaned_timelapse_sessions,
  273. )
  274. _active_sessions.clear()
  275. printer_dir = tmp_path / "timelapse_frames" / "1"
  276. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  277. mock_settings.base_dir = tmp_path
  278. session = TimelapseSession(1, 100, "/dev/video1", "usb")
  279. _active_sessions[1] = session
  280. import os
  281. old = time.time() - 600
  282. os.utime(session.frames_dir, (old, old))
  283. removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
  284. assert removed == 0
  285. assert session.frames_dir.exists()
  286. _active_sessions.clear()
  287. def test_spares_recently_modified_entries(self, tmp_path):
  288. """Defensive margin: something modified within min_age_seconds is
  289. left alone even if it doesn't match an active session, in case this
  290. is ever invoked while a session is mid-creation."""
  291. from backend.app.services.layer_timelapse import (
  292. _active_sessions,
  293. cleanup_orphaned_timelapse_sessions,
  294. )
  295. _active_sessions.clear()
  296. printer_dir = tmp_path / "timelapse_frames" / "1"
  297. printer_dir.mkdir(parents=True)
  298. (printer_dir / "20260101_000000").mkdir()
  299. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  300. mock_settings.base_dir = tmp_path
  301. removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
  302. assert removed == 0
  303. assert (printer_dir / "20260101_000000").exists()
  304. def test_no_base_dir_is_a_no_op(self, tmp_path):
  305. from backend.app.services.layer_timelapse import cleanup_orphaned_timelapse_sessions
  306. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  307. mock_settings.base_dir = tmp_path / "does-not-exist"
  308. removed = cleanup_orphaned_timelapse_sessions()
  309. assert removed == 0
  310. def test_ignores_non_numeric_printer_dirs(self, tmp_path):
  311. """Defensive: unrelated directories under timelapse_frames/ (there
  312. shouldn't be any, but printer_id is parsed from the dir name) must
  313. not raise."""
  314. from backend.app.services.layer_timelapse import (
  315. _active_sessions,
  316. cleanup_orphaned_timelapse_sessions,
  317. )
  318. _active_sessions.clear()
  319. (tmp_path / "timelapse_frames" / "not-a-printer-id").mkdir(parents=True)
  320. with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
  321. mock_settings.base_dir = tmp_path
  322. removed = cleanup_orphaned_timelapse_sessions()
  323. assert removed == 0