test_scheduler_auto_drying.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886
  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. @staticmethod
  358. def _make_setting(value):
  359. s = MagicMock()
  360. s.value = value
  361. return s
  362. @staticmethod
  363. def _make_db_side_effect(settings_map):
  364. """Create a side_effect for db.execute that returns settings by key."""
  365. async def side_effect(stmt):
  366. result = MagicMock()
  367. try:
  368. compiled = stmt.compile(compile_kwargs={"literal_binds": False})
  369. param_values = list(compiled.params.values())
  370. except Exception:
  371. param_values = []
  372. for key, val in settings_map.items():
  373. if key in param_values:
  374. result.scalar_one_or_none.return_value = val
  375. return result
  376. result.scalar_one_or_none.return_value = None
  377. return result
  378. return side_effect
  379. @pytest.mark.asyncio
  380. @patch("backend.app.services.print_scheduler.printer_manager")
  381. async def test_stops_when_no_scheduled_items(self, mock_pm, scheduler):
  382. """Auto-drying stops when queue has no scheduled items (queue mode only)."""
  383. scheduler._drying_in_progress = {1: time.monotonic()}
  384. state = MagicMock()
  385. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  386. mock_pm.get_status.return_value = state
  387. db = AsyncMock()
  388. settings_returns = {
  389. "queue_drying_enabled": self._make_setting("true"),
  390. "ambient_drying_enabled": self._make_setting("false"),
  391. }
  392. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  393. # Manual-start items only (no scheduled_time)
  394. item = MagicMock()
  395. item.printer_id = 1
  396. item.scheduled_time = None
  397. item.manual_start = True
  398. await scheduler._check_auto_drying(db, [item], set())
  399. # Should have stopped drying
  400. assert mock_pm.send_drying_command.called
  401. assert not scheduler._drying_in_progress
  402. @pytest.mark.asyncio
  403. @patch("backend.app.services.print_scheduler.printer_manager")
  404. async def test_stops_when_empty_queue(self, mock_pm, scheduler):
  405. """Auto-drying stops when queue is completely empty (queue mode only)."""
  406. scheduler._drying_in_progress = {1: time.monotonic()}
  407. state = MagicMock()
  408. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  409. mock_pm.get_status.return_value = state
  410. db = AsyncMock()
  411. settings_returns = {
  412. "queue_drying_enabled": self._make_setting("true"),
  413. "ambient_drying_enabled": self._make_setting("false"),
  414. }
  415. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  416. await scheduler._check_auto_drying(db, [], set())
  417. assert mock_pm.send_drying_command.called
  418. assert not scheduler._drying_in_progress
  419. class TestDryingTrackingTimestamps:
  420. """Test that _drying_in_progress uses timestamps, not booleans."""
  421. def test_initial_state_empty(self):
  422. """Fresh scheduler has no drying tracked."""
  423. scheduler = PrintScheduler()
  424. assert scheduler._drying_in_progress == {}
  425. def test_timestamp_is_monotonic(self):
  426. """Tracked values should be monotonic timestamps."""
  427. scheduler = PrintScheduler()
  428. before = time.monotonic()
  429. scheduler._drying_in_progress[1] = time.monotonic()
  430. after = time.monotonic()
  431. assert before <= scheduler._drying_in_progress[1] <= after
  432. def test_timestamp_is_truthy(self):
  433. """Timestamps are truthy for .get() checks (backward compat with bool pattern)."""
  434. scheduler = PrintScheduler()
  435. scheduler._drying_in_progress[1] = time.monotonic()
  436. assert scheduler._drying_in_progress.get(1)
  437. assert not scheduler._drying_in_progress.get(999)
  438. class _DryingTestBase:
  439. """Shared helpers for auto-drying integration tests."""
  440. @staticmethod
  441. def _make_setting(value):
  442. s = MagicMock()
  443. s.value = value
  444. return s
  445. @staticmethod
  446. def _make_db_side_effect(settings_map, printer_ids=None):
  447. """Create a side_effect for db.execute that returns settings by key and printers."""
  448. if printer_ids is None:
  449. printer_ids = [1]
  450. async def side_effect(stmt):
  451. result = MagicMock()
  452. stmt_str = str(stmt)
  453. try:
  454. compiled = stmt.compile(compile_kwargs={"literal_binds": False})
  455. param_values = list(compiled.params.values())
  456. except Exception:
  457. param_values = []
  458. for key, val in settings_map.items():
  459. if key in param_values:
  460. result.scalar_one_or_none.return_value = val
  461. return result
  462. if "printer" in stmt_str.lower() or "is_active" in stmt_str:
  463. printers = []
  464. for pid in printer_ids:
  465. p = MagicMock()
  466. p.id = pid
  467. p.is_active = True
  468. printers.append(p)
  469. scalars_mock = MagicMock()
  470. scalars_mock.__iter__ = MagicMock(return_value=iter(printers))
  471. result.scalars.return_value = scalars_mock
  472. else:
  473. result.scalar_one_or_none.return_value = None
  474. return result
  475. return side_effect
  476. class TestAmbientDrying(_DryingTestBase):
  477. """Tests for ambient drying mode — drying based on humidity regardless of queue state."""
  478. @pytest.fixture
  479. def scheduler(self):
  480. return PrintScheduler()
  481. @pytest.mark.asyncio
  482. @patch("backend.app.services.print_scheduler.printer_manager")
  483. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  484. async def test_ambient_dries_idle_printer_without_queue(self, mock_sd, mock_pm, scheduler):
  485. """Ambient mode starts drying on idle printers even with no queue items."""
  486. state = MagicMock()
  487. state.raw_data = {
  488. "ams": [
  489. {
  490. "id": 0,
  491. "module_type": "n3f",
  492. "dry_time": 0,
  493. "humidity_raw": "75",
  494. "dry_sf_reason": [],
  495. "tray": [{"tray_type": "PLA"}],
  496. }
  497. ]
  498. }
  499. state.firmware_version = "01.09.00.00"
  500. mock_pm.get_status.return_value = state
  501. mock_pm.is_connected.return_value = True
  502. mock_pm.get_model.return_value = "X1C"
  503. mock_pm.send_drying_command.return_value = True
  504. scheduler._is_printer_idle = MagicMock(return_value=True)
  505. db = AsyncMock()
  506. settings_returns = {
  507. "queue_drying_enabled": self._make_setting("false"),
  508. "ambient_drying_enabled": self._make_setting("true"),
  509. "ams_humidity_fair": self._make_setting("60"),
  510. "queue_drying_block": self._make_setting("false"),
  511. "drying_presets": None,
  512. }
  513. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  514. # Empty queue — ambient mode should still dry
  515. await scheduler._check_auto_drying(db, [], set())
  516. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  517. assert 1 in scheduler._drying_in_progress
  518. @pytest.mark.asyncio
  519. @patch("backend.app.services.print_scheduler.printer_manager")
  520. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  521. async def test_ambient_does_not_dry_below_threshold(self, mock_sd, mock_pm, scheduler):
  522. """Ambient mode does NOT dry when humidity is below threshold."""
  523. state = MagicMock()
  524. state.raw_data = {
  525. "ams": [
  526. {
  527. "id": 0,
  528. "module_type": "n3f",
  529. "dry_time": 0,
  530. "humidity_raw": "40",
  531. "dry_sf_reason": [],
  532. "tray": [{"tray_type": "PLA"}],
  533. }
  534. ]
  535. }
  536. state.firmware_version = "01.09.00.00"
  537. mock_pm.get_status.return_value = state
  538. mock_pm.is_connected.return_value = True
  539. mock_pm.get_model.return_value = "X1C"
  540. scheduler._is_printer_idle = MagicMock(return_value=True)
  541. db = AsyncMock()
  542. settings_returns = {
  543. "queue_drying_enabled": self._make_setting("false"),
  544. "ambient_drying_enabled": self._make_setting("true"),
  545. "ams_humidity_fair": self._make_setting("60"),
  546. "queue_drying_block": self._make_setting("false"),
  547. "drying_presets": None,
  548. }
  549. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  550. await scheduler._check_auto_drying(db, [], set())
  551. mock_pm.send_drying_command.assert_not_called()
  552. @pytest.mark.asyncio
  553. @patch("backend.app.services.print_scheduler.printer_manager")
  554. async def test_ambient_off_stops_drying_without_queue(self, mock_pm, scheduler):
  555. """Disabling ambient drying stops drying on printers without queue items."""
  556. scheduler._drying_in_progress = {1: time.monotonic()}
  557. state = MagicMock()
  558. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  559. mock_pm.get_status.return_value = state
  560. db = AsyncMock()
  561. settings_returns = {
  562. "queue_drying_enabled": self._make_setting("false"),
  563. "ambient_drying_enabled": self._make_setting("false"),
  564. }
  565. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  566. await scheduler._check_auto_drying(db, [], set())
  567. assert mock_pm.send_drying_command.called
  568. assert not scheduler._drying_in_progress
  569. @pytest.mark.asyncio
  570. @patch("backend.app.services.print_scheduler.printer_manager")
  571. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  572. async def test_ambient_continues_when_queue_empty(self, mock_sd, mock_pm, scheduler):
  573. """Ambient drying continues even when queue has no scheduled items (unlike queue mode)."""
  574. scheduler._drying_in_progress = {1: time.monotonic() - 100}
  575. state = MagicMock()
  576. state.raw_data = {
  577. "ams": [
  578. {
  579. "id": 0,
  580. "module_type": "n3f",
  581. "dry_time": 600,
  582. "humidity_raw": "75",
  583. "dry_sf_reason": [],
  584. "tray": [{"tray_type": "PLA"}],
  585. }
  586. ]
  587. }
  588. state.firmware_version = "01.09.00.00"
  589. mock_pm.get_status.return_value = state
  590. mock_pm.is_connected.return_value = True
  591. mock_pm.get_model.return_value = "X1C"
  592. scheduler._is_printer_idle = MagicMock(return_value=True)
  593. db = AsyncMock()
  594. settings_returns = {
  595. "queue_drying_enabled": self._make_setting("false"),
  596. "ambient_drying_enabled": self._make_setting("true"),
  597. "ams_humidity_fair": self._make_setting("60"),
  598. "queue_drying_block": self._make_setting("false"),
  599. "drying_presets": None,
  600. }
  601. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  602. await scheduler._check_auto_drying(db, [], set())
  603. # Should NOT have sent stop — humidity still high, drying continues
  604. for call in mock_pm.send_drying_command.call_args_list:
  605. assert call.kwargs.get("mode") != 0, "Should not stop drying in ambient mode with high humidity"
  606. assert 1 in scheduler._drying_in_progress
  607. @pytest.mark.asyncio
  608. @patch("backend.app.services.print_scheduler.printer_manager")
  609. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  610. async def test_queue_only_does_not_dry_without_scheduled_items(self, mock_sd, mock_pm, scheduler):
  611. """Queue mode alone does NOT dry printers that have no scheduled queue items."""
  612. state = MagicMock()
  613. state.raw_data = {
  614. "ams": [
  615. {
  616. "id": 0,
  617. "module_type": "n3f",
  618. "dry_time": 0,
  619. "humidity_raw": "75",
  620. "dry_sf_reason": [],
  621. "tray": [{"tray_type": "PLA"}],
  622. }
  623. ]
  624. }
  625. state.firmware_version = "01.09.00.00"
  626. mock_pm.get_status.return_value = state
  627. mock_pm.is_connected.return_value = True
  628. mock_pm.get_model.return_value = "X1C"
  629. scheduler._is_printer_idle = MagicMock(return_value=True)
  630. db = AsyncMock()
  631. settings_returns = {
  632. "queue_drying_enabled": self._make_setting("true"),
  633. "ambient_drying_enabled": self._make_setting("false"),
  634. "ams_humidity_fair": self._make_setting("60"),
  635. "queue_drying_block": self._make_setting("false"),
  636. "drying_presets": None,
  637. }
  638. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  639. # No queue items at all
  640. await scheduler._check_auto_drying(db, [], set())
  641. mock_pm.send_drying_command.assert_not_called()
  642. class TestBlockForDryingBugFix(_DryingTestBase):
  643. """Regression: block mode should not skip humidity auto-stop for already-drying printers."""
  644. @pytest.fixture
  645. def scheduler(self):
  646. s = PrintScheduler()
  647. s._min_drying_seconds = 1800
  648. return s
  649. @pytest.mark.asyncio
  650. @patch("backend.app.services.print_scheduler.printer_manager")
  651. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  652. async def test_block_mode_allows_humidity_stop_for_active_drying(self, mock_sd, mock_pm, scheduler):
  653. """Bug fix: printer already drying in block mode should still check humidity to auto-stop."""
  654. # Drying started 35 minutes ago
  655. scheduler._drying_in_progress = {1: time.monotonic() - 2100}
  656. state = MagicMock()
  657. state.raw_data = {
  658. "ams": [
  659. {
  660. "id": 0,
  661. "module_type": "n3f",
  662. "dry_time": 600,
  663. "humidity_raw": "30", # Below threshold
  664. "dry_sf_reason": [],
  665. "tray": [{"tray_type": "PLA"}],
  666. }
  667. ]
  668. }
  669. state.firmware_version = "01.09.00.00"
  670. mock_pm.get_status.return_value = state
  671. mock_pm.is_connected.return_value = True
  672. mock_pm.get_model.return_value = "X1C"
  673. scheduler._is_printer_idle = MagicMock(return_value=True)
  674. db = AsyncMock()
  675. settings_returns = {
  676. "queue_drying_enabled": self._make_setting("true"),
  677. "ambient_drying_enabled": self._make_setting("false"),
  678. "ams_humidity_fair": self._make_setting("60"),
  679. "queue_drying_block": self._make_setting("true"),
  680. "drying_presets": None,
  681. }
  682. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  683. # Queue item exists for this printer (triggers block mode gate)
  684. item = MagicMock()
  685. item.printer_id = 1
  686. item.scheduled_time = MagicMock()
  687. item.manual_start = False
  688. await scheduler._check_auto_drying(db, [item], set())
  689. # Should have sent stop command — humidity dropped below threshold after 30+ min
  690. mock_pm.send_drying_command.assert_any_call(1, 0, temp=0, duration=0, mode=0)
  691. @pytest.mark.asyncio
  692. @patch("backend.app.services.print_scheduler.printer_manager")
  693. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  694. async def test_block_mode_prevents_new_drying_start(self, mock_sd, mock_pm, scheduler):
  695. """Block mode should still prevent starting NEW drying on printers with pending items."""
  696. state = MagicMock()
  697. state.raw_data = {
  698. "ams": [
  699. {
  700. "id": 0,
  701. "module_type": "n3f",
  702. "dry_time": 0,
  703. "humidity_raw": "75",
  704. "dry_sf_reason": [],
  705. "tray": [{"tray_type": "PLA"}],
  706. }
  707. ]
  708. }
  709. state.firmware_version = "01.09.00.00"
  710. mock_pm.get_status.return_value = state
  711. mock_pm.is_connected.return_value = True
  712. mock_pm.get_model.return_value = "X1C"
  713. scheduler._is_printer_idle = MagicMock(return_value=True)
  714. db = AsyncMock()
  715. settings_returns = {
  716. "queue_drying_enabled": self._make_setting("true"),
  717. "ambient_drying_enabled": self._make_setting("false"),
  718. "ams_humidity_fair": self._make_setting("60"),
  719. "queue_drying_block": self._make_setting("true"),
  720. "drying_presets": None,
  721. }
  722. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  723. item = MagicMock()
  724. item.printer_id = 1
  725. item.scheduled_time = MagicMock()
  726. item.manual_start = False
  727. await scheduler._check_auto_drying(db, [item], set())
  728. # Should NOT start drying — block mode with pending items
  729. mock_pm.send_drying_command.assert_not_called()