test_scheduler_auto_drying.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. """Tests for the auto-drying feature in the print scheduler.
  2. Covers:
  3. - Conservative drying parameter selection (mixed filaments)
  4. - Drying preset loading (user-configured vs defaults)
  5. - Auto-drying lifecycle: start, humidity stop, minimum drying time
  6. - Auto-drying stop conditions: feature disabled, no scheduled items, per-printer
  7. - Sync drying state after restart
  8. """
  9. import time
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from backend.app.services.print_scheduler import PrintScheduler
  13. class TestConservativeDryingParams:
  14. """Test _get_conservative_drying_params — picks safest temp/duration for mixed filaments."""
  15. @pytest.fixture
  16. def scheduler(self):
  17. return PrintScheduler()
  18. def test_single_filament_pla(self, scheduler):
  19. """Single PLA tray uses PLA preset."""
  20. trays = [{"tray_type": "PLA"}]
  21. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  22. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  23. assert result == (45, 12, "PLA")
  24. def test_mixed_filaments_lowest_temp(self, scheduler):
  25. """Mixed PLA + ABS: should use PLA's 45°C (lowest), ABS's 12h (longest for n3f)."""
  26. trays = [{"tray_type": "PLA"}, {"tray_type": "ABS"}]
  27. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  28. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  29. temp, hours, _ = result
  30. assert temp == 45 # PLA is lowest
  31. assert hours == 12
  32. def test_mixed_filaments_longest_duration(self, scheduler):
  33. """Mixed ABS (8h) + PVA (18h) on n3s: should use longest duration."""
  34. trays = [{"tray_type": "ABS"}, {"tray_type": "PVA"}]
  35. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  36. result = scheduler._get_conservative_drying_params(trays, "n3s", presets)
  37. temp, hours, _ = result
  38. assert temp == 80 # ABS n3s=80, PVA n3s=85 → lowest=80
  39. assert hours == 18 # ABS n3s_hours=8, PVA n3s_hours=18 → longest=18
  40. def test_empty_trays_returns_none(self, scheduler):
  41. """No loaded trays returns None."""
  42. result = scheduler._get_conservative_drying_params([], "n3f", PrintScheduler.DEFAULT_DRYING_PRESETS)
  43. assert result is None
  44. def test_unknown_filament_skipped(self, scheduler):
  45. """Unknown filament types are ignored."""
  46. trays = [{"tray_type": "EXOTIC_WOOD"}]
  47. result = scheduler._get_conservative_drying_params(trays, "n3f", PrintScheduler.DEFAULT_DRYING_PRESETS)
  48. assert result is None
  49. def test_filament_type_normalization(self, scheduler):
  50. """'PLA Basic' should normalize to 'PLA'."""
  51. trays = [{"tray_type": "PLA Basic"}]
  52. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  53. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  54. assert result is not None
  55. assert result[0] == 45 # PLA temp
  56. def test_empty_tray_type_skipped(self, scheduler):
  57. """Trays with empty tray_type are skipped."""
  58. trays = [{"tray_type": ""}, {"tray_type": "PETG"}]
  59. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  60. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  61. assert result is not None
  62. assert result[2] == "PETG"
  63. def test_n3s_uses_n3s_keys(self, scheduler):
  64. """AMS-HT (n3s) should use n3s temp and n3s_hours."""
  65. trays = [{"tray_type": "TPU"}]
  66. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  67. result = scheduler._get_conservative_drying_params(trays, "n3s", presets)
  68. assert result == (75, 18, "TPU") # n3s=75, n3s_hours=18
  69. def test_n3f_uses_n3f_keys(self, scheduler):
  70. """AMS 2 Pro (n3f) should use n3f temp and n3f_hours."""
  71. trays = [{"tray_type": "TPU"}]
  72. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  73. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  74. assert result == (65, 12, "TPU") # n3f=65, n3f_hours=12
  75. def test_custom_presets(self, scheduler):
  76. """Custom presets override defaults."""
  77. trays = [{"tray_type": "PLA"}]
  78. custom = {"PLA": {"n3f": 50, "n3s": 50, "n3f_hours": 6, "n3s_hours": 6}}
  79. result = scheduler._get_conservative_drying_params(trays, "n3f", custom)
  80. assert result == (50, 6, "PLA")
  81. class TestDryingPresets:
  82. """Test _get_drying_presets — loads user presets from DB or falls back to defaults."""
  83. @pytest.fixture
  84. def scheduler(self):
  85. return PrintScheduler()
  86. @pytest.mark.asyncio
  87. async def test_default_presets_when_no_setting(self, scheduler):
  88. """Returns built-in defaults when no DB setting exists."""
  89. db = AsyncMock()
  90. result_mock = MagicMock()
  91. result_mock.scalar_one_or_none.return_value = None
  92. db.execute = AsyncMock(return_value=result_mock)
  93. presets = await scheduler._get_drying_presets(db)
  94. assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
  95. @pytest.mark.asyncio
  96. async def test_user_presets_from_db(self, scheduler):
  97. """Returns user-configured presets when saved in DB."""
  98. db = AsyncMock()
  99. setting = MagicMock()
  100. setting.value = '{"PLA": {"n3f": 50, "n3s": 50, "n3f_hours": 6, "n3s_hours": 6}}'
  101. result_mock = MagicMock()
  102. result_mock.scalar_one_or_none.return_value = setting
  103. db.execute = AsyncMock(return_value=result_mock)
  104. presets = await scheduler._get_drying_presets(db)
  105. assert presets["PLA"]["n3f"] == 50
  106. @pytest.mark.asyncio
  107. async def test_invalid_json_falls_back(self, scheduler):
  108. """Invalid JSON in DB falls back to defaults."""
  109. db = AsyncMock()
  110. setting = MagicMock()
  111. setting.value = "not valid json{{"
  112. result_mock = MagicMock()
  113. result_mock.scalar_one_or_none.return_value = setting
  114. db.execute = AsyncMock(return_value=result_mock)
  115. presets = await scheduler._get_drying_presets(db)
  116. assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
  117. @pytest.mark.asyncio
  118. async def test_empty_string_falls_back(self, scheduler):
  119. """Empty string in DB falls back to defaults."""
  120. db = AsyncMock()
  121. setting = MagicMock()
  122. setting.value = ""
  123. result_mock = MagicMock()
  124. result_mock.scalar_one_or_none.return_value = setting
  125. db.execute = AsyncMock(return_value=result_mock)
  126. presets = await scheduler._get_drying_presets(db)
  127. assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
  128. class TestSyncDryingState:
  129. """Test _sync_drying_state — syncs in-memory state with actual printer status."""
  130. @pytest.fixture
  131. def scheduler(self):
  132. return PrintScheduler()
  133. @patch("backend.app.services.print_scheduler.printer_manager")
  134. def test_removes_stopped_printers(self, mock_pm, scheduler):
  135. """Printers that stopped drying are removed from tracking."""
  136. scheduler._drying_in_progress = {1: time.monotonic()}
  137. state = MagicMock()
  138. state.raw_data = {"ams": [{"dry_time": 0}]}
  139. mock_pm.get_status.return_value = state
  140. scheduler._sync_drying_state()
  141. assert 1 not in scheduler._drying_in_progress
  142. @patch("backend.app.services.print_scheduler.printer_manager")
  143. def test_keeps_active_printers(self, mock_pm, scheduler):
  144. """Printers still drying remain in tracking."""
  145. ts = time.monotonic()
  146. scheduler._drying_in_progress = {1: ts}
  147. state = MagicMock()
  148. state.raw_data = {"ams": [{"dry_time": 120}]}
  149. mock_pm.get_status.return_value = state
  150. scheduler._sync_drying_state()
  151. assert scheduler._drying_in_progress[1] == ts
  152. @patch("backend.app.services.print_scheduler.printer_manager")
  153. def test_removes_disconnected_printers(self, mock_pm, scheduler):
  154. """Disconnected printers are removed from tracking."""
  155. scheduler._drying_in_progress = {1: time.monotonic()}
  156. mock_pm.get_status.return_value = None
  157. scheduler._sync_drying_state()
  158. assert 1 not in scheduler._drying_in_progress
  159. class TestStopDrying:
  160. """Test _stop_drying — sends stop commands and clears tracking."""
  161. @pytest.fixture
  162. def scheduler(self):
  163. return PrintScheduler()
  164. @pytest.mark.asyncio
  165. @patch("backend.app.services.print_scheduler.printer_manager")
  166. async def test_stops_all_ams_units(self, mock_pm, scheduler):
  167. """Sends stop command to each AMS unit that is drying."""
  168. scheduler._drying_in_progress = {1: time.monotonic()}
  169. state = MagicMock()
  170. state.raw_data = {
  171. "ams": [
  172. {"id": 0, "dry_time": 120},
  173. {"id": 1, "dry_time": 0},
  174. {"id": 128, "dry_time": 60},
  175. ]
  176. }
  177. mock_pm.get_status.return_value = state
  178. await scheduler._stop_drying(1)
  179. # Should send stop to AMS 0 and 128, not AMS 1
  180. calls = mock_pm.send_drying_command.call_args_list
  181. assert len(calls) == 2
  182. assert calls[0].args == (1, 0, 0, 0)
  183. assert calls[1].args == (1, 128, 0, 0)
  184. assert 1 not in scheduler._drying_in_progress
  185. @pytest.mark.asyncio
  186. @patch("backend.app.services.print_scheduler.printer_manager")
  187. async def test_clears_tracking_when_no_state(self, mock_pm, scheduler):
  188. """Clears tracking when printer has no state (disconnected)."""
  189. scheduler._drying_in_progress = {1: time.monotonic()}
  190. mock_pm.get_status.return_value = None
  191. await scheduler._stop_drying(1)
  192. assert 1 not in scheduler._drying_in_progress
  193. class TestMinimumDryingTime:
  194. """Regression: drying should not stop/restart rapidly when humidity oscillates near threshold."""
  195. @pytest.fixture
  196. def scheduler(self):
  197. s = PrintScheduler()
  198. s._min_drying_seconds = 1800 # 30 minutes
  199. return s
  200. @pytest.mark.asyncio
  201. @patch("backend.app.services.print_scheduler.printer_manager")
  202. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  203. async def test_no_stop_before_minimum_time(self, mock_sd, mock_pm, scheduler):
  204. """Drying should NOT stop when humidity drops below threshold before 30 min."""
  205. # Simulate: drying started 5 minutes ago
  206. scheduler._drying_in_progress = {1: time.monotonic() - 300}
  207. state = MagicMock()
  208. state.raw_data = {
  209. "ams": [
  210. {
  211. "id": 0,
  212. "module_type": "n3f",
  213. "dry_time": 600,
  214. "humidity_raw": "18",
  215. "dry_sf_reason": [],
  216. "tray": [{"tray_type": "PLA"}],
  217. }
  218. ]
  219. }
  220. state.firmware_version = "01.09.00.00"
  221. mock_pm.get_status.return_value = state
  222. mock_pm.is_connected.return_value = True
  223. mock_pm.get_model.return_value = "X1C"
  224. # Mock _is_printer_idle and DB
  225. scheduler._is_printer_idle = MagicMock(return_value=True)
  226. db = AsyncMock()
  227. # Mock settings: enabled, threshold=21
  228. settings_returns = {
  229. "queue_drying_enabled": self._make_setting("true"),
  230. "ams_humidity_fair": self._make_setting("21"),
  231. "queue_drying_block": self._make_setting("false"),
  232. "drying_presets": None,
  233. }
  234. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))
  235. # Queue item with schedule
  236. item = MagicMock()
  237. item.printer_id = 1
  238. item.scheduled_time = MagicMock() # Has a schedule
  239. item.manual_start = False
  240. await scheduler._check_auto_drying(db, [item], set())
  241. # Should NOT have sent stop command via humidity check — minimum time not elapsed
  242. # The only calls should NOT include the humidity-based stop
  243. for call in mock_pm.send_drying_command.call_args_list:
  244. # If any stop was called, it should NOT be from the humidity path
  245. # (humidity path uses keyword args: temp=0, duration=0, mode=0)
  246. assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
  247. "Humidity-based stop should not fire before minimum drying time"
  248. )
  249. @pytest.mark.asyncio
  250. @patch("backend.app.services.print_scheduler.printer_manager")
  251. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  252. async def test_stops_after_minimum_time(self, mock_sd, mock_pm, scheduler):
  253. """Drying SHOULD stop when humidity below threshold AND 30 min elapsed."""
  254. # Simulate: drying started 35 minutes ago
  255. scheduler._drying_in_progress = {1: time.monotonic() - 2100}
  256. state = MagicMock()
  257. state.raw_data = {
  258. "ams": [
  259. {
  260. "id": 0,
  261. "module_type": "n3f",
  262. "dry_time": 600,
  263. "humidity_raw": "18",
  264. "dry_sf_reason": [],
  265. "tray": [{"tray_type": "PLA"}],
  266. }
  267. ]
  268. }
  269. state.firmware_version = "01.09.00.00"
  270. mock_pm.get_status.return_value = state
  271. mock_pm.is_connected.return_value = True
  272. mock_pm.get_model.return_value = "X1C"
  273. scheduler._is_printer_idle = MagicMock(return_value=True)
  274. db = AsyncMock()
  275. settings_returns = {
  276. "queue_drying_enabled": self._make_setting("true"),
  277. "ams_humidity_fair": self._make_setting("21"),
  278. "queue_drying_block": self._make_setting("false"),
  279. "drying_presets": None,
  280. }
  281. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))
  282. item = MagicMock()
  283. item.printer_id = 1
  284. item.scheduled_time = MagicMock()
  285. item.manual_start = False
  286. await scheduler._check_auto_drying(db, [item], set())
  287. # Should have sent stop command (humidity-based stop after minimum time)
  288. mock_pm.send_drying_command.assert_any_call(1, 0, temp=0, duration=0, mode=0)
  289. @staticmethod
  290. def _make_setting(value):
  291. s = MagicMock()
  292. s.value = value
  293. return s
  294. @staticmethod
  295. def _make_db_side_effect(settings_map, printer_id=1):
  296. """Create a side_effect for db.execute that returns settings and printers."""
  297. async def side_effect(stmt):
  298. result = MagicMock()
  299. stmt_str = str(stmt)
  300. # Extract bind parameter values (SQLAlchemy uses :key_1 placeholders)
  301. try:
  302. compiled = stmt.compile(compile_kwargs={"literal_binds": False})
  303. param_values = list(compiled.params.values())
  304. except Exception:
  305. param_values = []
  306. # Match settings queries by checking bind parameter values
  307. matched = False
  308. for key, val in settings_map.items():
  309. if key in param_values:
  310. result.scalar_one_or_none.return_value = val
  311. matched = True
  312. break
  313. if not matched:
  314. if "printer" in stmt_str.lower() or "is_active" in stmt_str:
  315. printer = MagicMock()
  316. printer.id = printer_id
  317. printer.is_active = True
  318. scalars_mock = MagicMock()
  319. scalars_mock.__iter__ = MagicMock(return_value=iter([printer]))
  320. result.scalars.return_value = scalars_mock
  321. else:
  322. result.scalar_one_or_none.return_value = None
  323. return result
  324. return side_effect
  325. class TestAutoStopOnFeatureDisabled:
  326. """Regression: disabling auto-drying in settings should stop active drying sessions."""
  327. @pytest.fixture
  328. def scheduler(self):
  329. return PrintScheduler()
  330. @pytest.mark.asyncio
  331. @patch("backend.app.services.print_scheduler.printer_manager")
  332. async def test_stops_drying_when_disabled(self, mock_pm, scheduler):
  333. """Disabling auto-drying should send stop commands to all drying printers."""
  334. scheduler._drying_in_progress = {1: time.monotonic(), 2: time.monotonic()}
  335. # Printer 1: drying, Printer 2: drying
  336. def get_status(pid):
  337. state = MagicMock()
  338. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  339. return state
  340. mock_pm.get_status.side_effect = get_status
  341. db = AsyncMock()
  342. # queue_drying_enabled = false
  343. setting = MagicMock()
  344. setting.value = "false"
  345. result_mock = MagicMock()
  346. result_mock.scalar_one_or_none.return_value = setting
  347. db.execute = AsyncMock(return_value=result_mock)
  348. await scheduler._check_auto_drying(db, [], set())
  349. # Should have sent stop commands
  350. assert mock_pm.send_drying_command.call_count == 2
  351. assert not scheduler._drying_in_progress
  352. class TestAutoStopOnNoScheduledItems:
  353. """Regression: removing scheduled items should stop auto-drying."""
  354. @pytest.fixture
  355. def scheduler(self):
  356. return PrintScheduler()
  357. @pytest.mark.asyncio
  358. @patch("backend.app.services.print_scheduler.printer_manager")
  359. async def test_stops_when_no_scheduled_items(self, mock_pm, scheduler):
  360. """Auto-drying stops when queue has no scheduled items."""
  361. scheduler._drying_in_progress = {1: time.monotonic()}
  362. state = MagicMock()
  363. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  364. mock_pm.get_status.return_value = state
  365. db = AsyncMock()
  366. # queue_drying_enabled = true
  367. enabled_setting = MagicMock()
  368. enabled_setting.value = "true"
  369. call_count = [0]
  370. async def db_execute(stmt):
  371. call_count[0] += 1
  372. result = MagicMock()
  373. result.scalar_one_or_none.return_value = enabled_setting
  374. return result
  375. db.execute = AsyncMock(side_effect=db_execute)
  376. # Manual-start items only (no scheduled_time)
  377. item = MagicMock()
  378. item.printer_id = 1
  379. item.scheduled_time = None
  380. item.manual_start = True
  381. await scheduler._check_auto_drying(db, [item], set())
  382. # Should have stopped drying
  383. assert mock_pm.send_drying_command.called
  384. assert not scheduler._drying_in_progress
  385. @pytest.mark.asyncio
  386. @patch("backend.app.services.print_scheduler.printer_manager")
  387. async def test_stops_when_empty_queue(self, mock_pm, scheduler):
  388. """Auto-drying stops when queue is completely empty."""
  389. scheduler._drying_in_progress = {1: time.monotonic()}
  390. state = MagicMock()
  391. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  392. mock_pm.get_status.return_value = state
  393. db = AsyncMock()
  394. enabled_setting = MagicMock()
  395. enabled_setting.value = "true"
  396. result_mock = MagicMock()
  397. result_mock.scalar_one_or_none.return_value = enabled_setting
  398. db.execute = AsyncMock(return_value=result_mock)
  399. await scheduler._check_auto_drying(db, [], set())
  400. assert mock_pm.send_drying_command.called
  401. assert not scheduler._drying_in_progress
  402. class TestDryingTrackingTimestamps:
  403. """Test that _drying_in_progress uses timestamps, not booleans."""
  404. def test_initial_state_empty(self):
  405. """Fresh scheduler has no drying tracked."""
  406. scheduler = PrintScheduler()
  407. assert scheduler._drying_in_progress == {}
  408. def test_timestamp_is_monotonic(self):
  409. """Tracked values should be monotonic timestamps."""
  410. scheduler = PrintScheduler()
  411. before = time.monotonic()
  412. scheduler._drying_in_progress[1] = time.monotonic()
  413. after = time.monotonic()
  414. assert before <= scheduler._drying_in_progress[1] <= after
  415. def test_timestamp_is_truthy(self):
  416. """Timestamps are truthy for .get() checks (backward compat with bool pattern)."""
  417. scheduler = PrintScheduler()
  418. scheduler._drying_in_progress[1] = time.monotonic()
  419. assert scheduler._drying_in_progress.get(1)
  420. assert not scheduler._drying_in_progress.get(999)