test_scheduler_auto_drying.py 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194
  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 #1892: a running drying cycle must never be stopped by a humidity re-check.
  195. Relative humidity reads low in heated air (the AMS sensor sees ~15-20% within
  196. minutes of the dryer starting even while the filament is still saturated), so a
  197. humidity-based auto-stop would truncate every cycle — manual or Bambuddy-started —
  198. to the old minimum-time floor. Drying is now left to run to its configured
  199. duration; the firmware stops it when the duration elapses.
  200. """
  201. @pytest.fixture
  202. def scheduler(self):
  203. return PrintScheduler()
  204. @pytest.mark.asyncio
  205. @patch("backend.app.services.print_scheduler.printer_manager")
  206. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  207. async def test_no_stop_before_minimum_time(self, mock_sd, mock_pm, scheduler):
  208. """Drying should NOT stop when humidity drops below threshold shortly after start."""
  209. # Simulate: drying started 5 minutes ago
  210. scheduler._drying_in_progress = {1: time.monotonic() - 300}
  211. state = MagicMock()
  212. state.raw_data = {
  213. "ams": [
  214. {
  215. "id": 0,
  216. "module_type": "n3f",
  217. "dry_time": 600,
  218. "humidity_raw": "18",
  219. "dry_sf_reason": [],
  220. "tray": [{"tray_type": "PLA"}],
  221. }
  222. ]
  223. }
  224. state.firmware_version = "01.09.00.00"
  225. mock_pm.get_status.return_value = state
  226. mock_pm.is_connected.return_value = True
  227. mock_pm.get_model.return_value = "X1C"
  228. # Mock _is_printer_idle and DB
  229. scheduler._is_printer_idle = MagicMock(return_value=True)
  230. db = AsyncMock()
  231. # Mock settings: enabled, threshold=21
  232. settings_returns = {
  233. "queue_drying_enabled": self._make_setting("true"),
  234. "ams_humidity_fair": self._make_setting("21"),
  235. "queue_drying_block": self._make_setting("false"),
  236. "drying_presets": None,
  237. }
  238. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))
  239. # Queue item with schedule
  240. item = MagicMock()
  241. item.printer_id = 1
  242. item.scheduled_time = MagicMock() # Has a schedule
  243. item.manual_start = False
  244. await scheduler._check_auto_drying(db, [item], set())
  245. # Should NOT have sent stop command via humidity check — minimum time not elapsed
  246. # The only calls should NOT include the humidity-based stop
  247. for call in mock_pm.send_drying_command.call_args_list:
  248. # If any stop was called, it should NOT be from the humidity path
  249. # (humidity path uses keyword args: temp=0, duration=0, mode=0)
  250. assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
  251. "Humidity-based stop should not fire before minimum drying time"
  252. )
  253. @pytest.mark.asyncio
  254. @patch("backend.app.services.print_scheduler.printer_manager")
  255. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  256. async def test_no_stop_after_long_elapsed_time(self, mock_sd, mock_pm, scheduler):
  257. """#1892: drying must NOT stop even long after start with low humidity — let it run."""
  258. # Simulate: drying started 35 minutes ago, humidity reads low (heated air)
  259. scheduler._drying_in_progress = {1: time.monotonic() - 2100}
  260. state = MagicMock()
  261. state.raw_data = {
  262. "ams": [
  263. {
  264. "id": 0,
  265. "module_type": "n3f",
  266. "dry_time": 600,
  267. "humidity_raw": "18",
  268. "dry_sf_reason": [],
  269. "tray": [{"tray_type": "PLA"}],
  270. }
  271. ]
  272. }
  273. state.firmware_version = "01.09.00.00"
  274. mock_pm.get_status.return_value = state
  275. mock_pm.is_connected.return_value = True
  276. mock_pm.get_model.return_value = "X1C"
  277. scheduler._is_printer_idle = MagicMock(return_value=True)
  278. db = AsyncMock()
  279. settings_returns = {
  280. "queue_drying_enabled": self._make_setting("true"),
  281. "ams_humidity_fair": self._make_setting("21"),
  282. "queue_drying_block": self._make_setting("false"),
  283. "drying_presets": None,
  284. }
  285. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))
  286. item = MagicMock()
  287. item.printer_id = 1
  288. item.scheduled_time = MagicMock()
  289. item.manual_start = False
  290. await scheduler._check_auto_drying(db, [item], set())
  291. # Must NOT send a humidity-based stop — drying is left to run to its duration
  292. for call in mock_pm.send_drying_command.call_args_list:
  293. assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
  294. "Humidity re-check must never stop a running drying cycle (#1892)"
  295. )
  296. @staticmethod
  297. def _make_setting(value):
  298. s = MagicMock()
  299. s.value = value
  300. return s
  301. @staticmethod
  302. def _make_db_side_effect(settings_map, printer_id=1):
  303. """Create a side_effect for db.execute that returns settings and printers."""
  304. async def side_effect(stmt):
  305. result = MagicMock()
  306. stmt_str = str(stmt)
  307. # Extract bind parameter values (SQLAlchemy uses :key_1 placeholders)
  308. try:
  309. compiled = stmt.compile(compile_kwargs={"literal_binds": False})
  310. param_values = list(compiled.params.values())
  311. except Exception:
  312. param_values = []
  313. # Match settings queries by checking bind parameter values
  314. matched = False
  315. for key, val in settings_map.items():
  316. if key in param_values:
  317. result.scalar_one_or_none.return_value = val
  318. matched = True
  319. break
  320. if not matched:
  321. if "printer" in stmt_str.lower() or "is_active" in stmt_str:
  322. printer = MagicMock()
  323. printer.id = printer_id
  324. printer.is_active = True
  325. scalars_mock = MagicMock()
  326. scalars_mock.__iter__ = MagicMock(return_value=iter([printer]))
  327. result.scalars.return_value = scalars_mock
  328. else:
  329. result.scalar_one_or_none.return_value = None
  330. return result
  331. return side_effect
  332. class TestAutoStopOnFeatureDisabled:
  333. """Regression: disabling auto-drying in settings should stop active drying sessions."""
  334. @pytest.fixture
  335. def scheduler(self):
  336. return PrintScheduler()
  337. @pytest.mark.asyncio
  338. @patch("backend.app.services.print_scheduler.printer_manager")
  339. async def test_stops_drying_when_disabled(self, mock_pm, scheduler):
  340. """Disabling auto-drying should send stop commands to all drying printers."""
  341. scheduler._drying_in_progress = {1: time.monotonic(), 2: time.monotonic()}
  342. # Printer 1: drying, Printer 2: drying
  343. def get_status(pid):
  344. state = MagicMock()
  345. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  346. return state
  347. mock_pm.get_status.side_effect = get_status
  348. db = AsyncMock()
  349. # queue_drying_enabled = false
  350. setting = MagicMock()
  351. setting.value = "false"
  352. result_mock = MagicMock()
  353. result_mock.scalar_one_or_none.return_value = setting
  354. db.execute = AsyncMock(return_value=result_mock)
  355. await scheduler._check_auto_drying(db, [], set())
  356. # Should have sent stop commands
  357. assert mock_pm.send_drying_command.call_count == 2
  358. assert not scheduler._drying_in_progress
  359. class TestAutoStopOnNoScheduledItems:
  360. """Regression: removing scheduled items should stop auto-drying."""
  361. @pytest.fixture
  362. def scheduler(self):
  363. return PrintScheduler()
  364. @staticmethod
  365. def _make_setting(value):
  366. s = MagicMock()
  367. s.value = value
  368. return s
  369. @staticmethod
  370. def _make_db_side_effect(settings_map):
  371. """Create a side_effect for db.execute that returns settings by key."""
  372. async def side_effect(stmt):
  373. result = MagicMock()
  374. try:
  375. compiled = stmt.compile(compile_kwargs={"literal_binds": False})
  376. param_values = list(compiled.params.values())
  377. except Exception:
  378. param_values = []
  379. for key, val in settings_map.items():
  380. if key in param_values:
  381. result.scalar_one_or_none.return_value = val
  382. return result
  383. result.scalar_one_or_none.return_value = None
  384. return result
  385. return side_effect
  386. @pytest.mark.asyncio
  387. @patch("backend.app.services.print_scheduler.printer_manager")
  388. async def test_stops_when_no_scheduled_items(self, mock_pm, scheduler):
  389. """Auto-drying stops when queue has no scheduled items (queue mode only)."""
  390. scheduler._drying_in_progress = {1: time.monotonic()}
  391. state = MagicMock()
  392. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  393. mock_pm.get_status.return_value = state
  394. db = AsyncMock()
  395. settings_returns = {
  396. "queue_drying_enabled": self._make_setting("true"),
  397. "ambient_drying_enabled": self._make_setting("false"),
  398. }
  399. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  400. # Manual-start items only (no scheduled_time)
  401. item = MagicMock()
  402. item.printer_id = 1
  403. item.scheduled_time = None
  404. item.manual_start = True
  405. await scheduler._check_auto_drying(db, [item], set())
  406. # Should have stopped drying
  407. assert mock_pm.send_drying_command.called
  408. assert not scheduler._drying_in_progress
  409. @pytest.mark.asyncio
  410. @patch("backend.app.services.print_scheduler.printer_manager")
  411. async def test_stops_when_empty_queue(self, mock_pm, scheduler):
  412. """Auto-drying stops when queue is completely empty (queue mode only)."""
  413. scheduler._drying_in_progress = {1: time.monotonic()}
  414. state = MagicMock()
  415. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  416. mock_pm.get_status.return_value = state
  417. db = AsyncMock()
  418. settings_returns = {
  419. "queue_drying_enabled": self._make_setting("true"),
  420. "ambient_drying_enabled": self._make_setting("false"),
  421. }
  422. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  423. await scheduler._check_auto_drying(db, [], set())
  424. assert mock_pm.send_drying_command.called
  425. assert not scheduler._drying_in_progress
  426. class TestDryingTrackingTimestamps:
  427. """Test that _drying_in_progress uses timestamps, not booleans."""
  428. def test_initial_state_empty(self):
  429. """Fresh scheduler has no drying tracked."""
  430. scheduler = PrintScheduler()
  431. assert scheduler._drying_in_progress == {}
  432. def test_timestamp_is_monotonic(self):
  433. """Tracked values should be monotonic timestamps."""
  434. scheduler = PrintScheduler()
  435. before = time.monotonic()
  436. scheduler._drying_in_progress[1] = time.monotonic()
  437. after = time.monotonic()
  438. assert before <= scheduler._drying_in_progress[1] <= after
  439. def test_timestamp_is_truthy(self):
  440. """Timestamps are truthy for .get() checks (backward compat with bool pattern)."""
  441. scheduler = PrintScheduler()
  442. scheduler._drying_in_progress[1] = time.monotonic()
  443. assert scheduler._drying_in_progress.get(1)
  444. assert not scheduler._drying_in_progress.get(999)
  445. class _DryingTestBase:
  446. """Shared helpers for auto-drying integration tests."""
  447. @staticmethod
  448. def _make_setting(value):
  449. s = MagicMock()
  450. s.value = value
  451. return s
  452. @staticmethod
  453. def _make_db_side_effect(settings_map, printer_ids=None):
  454. """Create a side_effect for db.execute that returns settings by key and printers."""
  455. if printer_ids is None:
  456. printer_ids = [1]
  457. async def side_effect(stmt):
  458. result = MagicMock()
  459. stmt_str = str(stmt)
  460. try:
  461. compiled = stmt.compile(compile_kwargs={"literal_binds": False})
  462. param_values = list(compiled.params.values())
  463. except Exception:
  464. param_values = []
  465. for key, val in settings_map.items():
  466. if key in param_values:
  467. result.scalar_one_or_none.return_value = val
  468. return result
  469. if "printer" in stmt_str.lower() or "is_active" in stmt_str:
  470. printers = []
  471. for pid in printer_ids:
  472. p = MagicMock()
  473. p.id = pid
  474. p.is_active = True
  475. printers.append(p)
  476. scalars_mock = MagicMock()
  477. scalars_mock.__iter__ = MagicMock(return_value=iter(printers))
  478. result.scalars.return_value = scalars_mock
  479. else:
  480. result.scalar_one_or_none.return_value = None
  481. return result
  482. return side_effect
  483. class TestAmbientDrying(_DryingTestBase):
  484. """Tests for ambient drying mode — drying based on humidity regardless of queue state."""
  485. @pytest.fixture
  486. def scheduler(self):
  487. return PrintScheduler()
  488. @pytest.mark.asyncio
  489. @patch("backend.app.services.print_scheduler.printer_manager")
  490. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  491. async def test_ambient_dries_idle_printer_without_queue(self, mock_sd, mock_pm, scheduler):
  492. """Ambient mode starts drying on idle printers even with no queue items."""
  493. state = MagicMock()
  494. state.raw_data = {
  495. "ams": [
  496. {
  497. "id": 0,
  498. "module_type": "n3f",
  499. "dry_time": 0,
  500. "humidity_raw": "75",
  501. "dry_sf_reason": [],
  502. "tray": [{"tray_type": "PLA"}],
  503. }
  504. ]
  505. }
  506. state.firmware_version = "01.09.00.00"
  507. mock_pm.get_status.return_value = state
  508. mock_pm.is_connected.return_value = True
  509. mock_pm.get_model.return_value = "X1C"
  510. mock_pm.send_drying_command.return_value = True
  511. scheduler._is_printer_idle = MagicMock(return_value=True)
  512. db = AsyncMock()
  513. settings_returns = {
  514. "queue_drying_enabled": self._make_setting("false"),
  515. "ambient_drying_enabled": self._make_setting("true"),
  516. "ams_humidity_fair": self._make_setting("60"),
  517. "queue_drying_block": self._make_setting("false"),
  518. "drying_presets": None,
  519. }
  520. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  521. # Empty queue — ambient mode should still dry
  522. await scheduler._check_auto_drying(db, [], set())
  523. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  524. assert 1 in scheduler._drying_in_progress
  525. @pytest.mark.asyncio
  526. @patch("backend.app.services.print_scheduler.printer_manager")
  527. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  528. async def test_ambient_does_not_dry_below_threshold(self, mock_sd, mock_pm, scheduler):
  529. """Ambient mode does NOT dry when humidity is below threshold."""
  530. state = MagicMock()
  531. state.raw_data = {
  532. "ams": [
  533. {
  534. "id": 0,
  535. "module_type": "n3f",
  536. "dry_time": 0,
  537. "humidity_raw": "40",
  538. "dry_sf_reason": [],
  539. "tray": [{"tray_type": "PLA"}],
  540. }
  541. ]
  542. }
  543. state.firmware_version = "01.09.00.00"
  544. mock_pm.get_status.return_value = state
  545. mock_pm.is_connected.return_value = True
  546. mock_pm.get_model.return_value = "X1C"
  547. scheduler._is_printer_idle = MagicMock(return_value=True)
  548. db = AsyncMock()
  549. settings_returns = {
  550. "queue_drying_enabled": self._make_setting("false"),
  551. "ambient_drying_enabled": self._make_setting("true"),
  552. "ams_humidity_fair": self._make_setting("60"),
  553. "queue_drying_block": self._make_setting("false"),
  554. "drying_presets": None,
  555. }
  556. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  557. await scheduler._check_auto_drying(db, [], set())
  558. mock_pm.send_drying_command.assert_not_called()
  559. @pytest.mark.asyncio
  560. @patch("backend.app.services.print_scheduler.printer_manager")
  561. async def test_ambient_off_stops_drying_without_queue(self, mock_pm, scheduler):
  562. """Disabling ambient drying stops drying on printers without queue items."""
  563. scheduler._drying_in_progress = {1: time.monotonic()}
  564. state = MagicMock()
  565. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  566. mock_pm.get_status.return_value = state
  567. db = AsyncMock()
  568. settings_returns = {
  569. "queue_drying_enabled": self._make_setting("false"),
  570. "ambient_drying_enabled": self._make_setting("false"),
  571. }
  572. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  573. await scheduler._check_auto_drying(db, [], set())
  574. assert mock_pm.send_drying_command.called
  575. assert not scheduler._drying_in_progress
  576. @pytest.mark.asyncio
  577. @patch("backend.app.services.print_scheduler.printer_manager")
  578. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  579. async def test_ambient_continues_when_queue_empty(self, mock_sd, mock_pm, scheduler):
  580. """Ambient drying continues even when queue has no scheduled items (unlike queue mode)."""
  581. scheduler._drying_in_progress = {1: time.monotonic() - 100}
  582. state = MagicMock()
  583. state.raw_data = {
  584. "ams": [
  585. {
  586. "id": 0,
  587. "module_type": "n3f",
  588. "dry_time": 600,
  589. "humidity_raw": "75",
  590. "dry_sf_reason": [],
  591. "tray": [{"tray_type": "PLA"}],
  592. }
  593. ]
  594. }
  595. state.firmware_version = "01.09.00.00"
  596. mock_pm.get_status.return_value = state
  597. mock_pm.is_connected.return_value = True
  598. mock_pm.get_model.return_value = "X1C"
  599. scheduler._is_printer_idle = MagicMock(return_value=True)
  600. db = AsyncMock()
  601. settings_returns = {
  602. "queue_drying_enabled": self._make_setting("false"),
  603. "ambient_drying_enabled": self._make_setting("true"),
  604. "ams_humidity_fair": self._make_setting("60"),
  605. "queue_drying_block": self._make_setting("false"),
  606. "drying_presets": None,
  607. }
  608. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  609. await scheduler._check_auto_drying(db, [], set())
  610. # Should NOT have sent stop — humidity still high, drying continues
  611. for call in mock_pm.send_drying_command.call_args_list:
  612. assert call.kwargs.get("mode") != 0, "Should not stop drying in ambient mode with high humidity"
  613. assert 1 in scheduler._drying_in_progress
  614. @pytest.mark.asyncio
  615. @patch("backend.app.services.print_scheduler.printer_manager")
  616. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  617. async def test_queue_only_does_not_dry_without_scheduled_items(self, mock_sd, mock_pm, scheduler):
  618. """Queue mode alone does NOT dry printers that have no scheduled queue items."""
  619. state = MagicMock()
  620. state.raw_data = {
  621. "ams": [
  622. {
  623. "id": 0,
  624. "module_type": "n3f",
  625. "dry_time": 0,
  626. "humidity_raw": "75",
  627. "dry_sf_reason": [],
  628. "tray": [{"tray_type": "PLA"}],
  629. }
  630. ]
  631. }
  632. state.firmware_version = "01.09.00.00"
  633. mock_pm.get_status.return_value = state
  634. mock_pm.is_connected.return_value = True
  635. mock_pm.get_model.return_value = "X1C"
  636. scheduler._is_printer_idle = MagicMock(return_value=True)
  637. db = AsyncMock()
  638. settings_returns = {
  639. "queue_drying_enabled": self._make_setting("true"),
  640. "ambient_drying_enabled": self._make_setting("false"),
  641. "ams_humidity_fair": self._make_setting("60"),
  642. "queue_drying_block": self._make_setting("false"),
  643. "drying_presets": None,
  644. }
  645. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  646. # No queue items at all
  647. await scheduler._check_auto_drying(db, [], set())
  648. mock_pm.send_drying_command.assert_not_called()
  649. class TestBlockForDryingBugFix(_DryingTestBase):
  650. """Regression: block mode gates NEW drying starts but must leave running dries alone (#1892)."""
  651. @pytest.fixture
  652. def scheduler(self):
  653. return PrintScheduler()
  654. @pytest.mark.asyncio
  655. @patch("backend.app.services.print_scheduler.printer_manager")
  656. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  657. async def test_block_mode_leaves_active_drying_running(self, mock_sd, mock_pm, scheduler):
  658. """#1892: a printer already drying in block mode must not be stopped by a humidity re-check."""
  659. # Drying started 35 minutes ago
  660. scheduler._drying_in_progress = {1: time.monotonic() - 2100}
  661. state = MagicMock()
  662. state.raw_data = {
  663. "ams": [
  664. {
  665. "id": 0,
  666. "module_type": "n3f",
  667. "dry_time": 600,
  668. "humidity_raw": "30", # Below threshold
  669. "dry_sf_reason": [],
  670. "tray": [{"tray_type": "PLA"}],
  671. }
  672. ]
  673. }
  674. state.firmware_version = "01.09.00.00"
  675. mock_pm.get_status.return_value = state
  676. mock_pm.is_connected.return_value = True
  677. mock_pm.get_model.return_value = "X1C"
  678. scheduler._is_printer_idle = MagicMock(return_value=True)
  679. db = AsyncMock()
  680. settings_returns = {
  681. "queue_drying_enabled": self._make_setting("true"),
  682. "ambient_drying_enabled": self._make_setting("false"),
  683. "ams_humidity_fair": self._make_setting("60"),
  684. "queue_drying_block": self._make_setting("true"),
  685. "drying_presets": None,
  686. }
  687. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  688. # Queue item exists for this printer (triggers block mode gate)
  689. item = MagicMock()
  690. item.printer_id = 1
  691. item.scheduled_time = MagicMock()
  692. item.manual_start = False
  693. await scheduler._check_auto_drying(db, [item], set())
  694. # Must NOT stop the running dry — block mode gates new starts, not active cycles
  695. for call in mock_pm.send_drying_command.call_args_list:
  696. assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
  697. "Block mode must not stop an already-running drying cycle (#1892)"
  698. )
  699. @pytest.mark.asyncio
  700. @patch("backend.app.services.print_scheduler.printer_manager")
  701. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  702. async def test_block_mode_prevents_new_drying_start(self, mock_sd, mock_pm, scheduler):
  703. """Block mode should still prevent starting NEW drying on printers with pending items."""
  704. state = MagicMock()
  705. state.raw_data = {
  706. "ams": [
  707. {
  708. "id": 0,
  709. "module_type": "n3f",
  710. "dry_time": 0,
  711. "humidity_raw": "75",
  712. "dry_sf_reason": [],
  713. "tray": [{"tray_type": "PLA"}],
  714. }
  715. ]
  716. }
  717. state.firmware_version = "01.09.00.00"
  718. mock_pm.get_status.return_value = state
  719. mock_pm.is_connected.return_value = True
  720. mock_pm.get_model.return_value = "X1C"
  721. scheduler._is_printer_idle = MagicMock(return_value=True)
  722. db = AsyncMock()
  723. settings_returns = {
  724. "queue_drying_enabled": self._make_setting("true"),
  725. "ambient_drying_enabled": self._make_setting("false"),
  726. "ams_humidity_fair": self._make_setting("60"),
  727. "queue_drying_block": self._make_setting("true"),
  728. "drying_presets": None,
  729. }
  730. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  731. item = MagicMock()
  732. item.printer_id = 1
  733. item.scheduled_time = MagicMock()
  734. item.manual_start = False
  735. await scheduler._check_auto_drying(db, [item], set())
  736. # Should NOT start drying — block mode with pending items
  737. mock_pm.send_drying_command.assert_not_called()
  738. class TestResolveHumidityThreshold:
  739. """Per-filament humidity threshold resolver (#1605).
  740. Resolves the trigger threshold for an AMS unit from the loaded tray types.
  741. Mixed loads use the lowest (most restrictive) value. Empty / unloaded trays
  742. contribute no constraint; falls back to the global ``ams_humidity_fair``
  743. when no per-type overrides are configured.
  744. """
  745. def test_no_overrides_falls_back_to_global(self):
  746. """Empty overrides map → caller's global fallback is used verbatim."""
  747. result = PrintScheduler.resolve_humidity_threshold([{"tray_type": "PLA"}], {}, 60)
  748. assert result == 60
  749. def test_single_known_type_uses_override(self):
  750. """Single PLA tray with override = 50 returns 50."""
  751. result = PrintScheduler.resolve_humidity_threshold(
  752. [{"tray_type": "PLA Basic"}],
  753. {"default": 60, "PLA": 50},
  754. 60,
  755. )
  756. assert result == 50
  757. def test_mixed_load_picks_lowest(self):
  758. """Mixed PLA (60) + Nylon (20) → most restrictive = 20."""
  759. result = PrintScheduler.resolve_humidity_threshold(
  760. [{"tray_type": "PLA Basic"}, {"tray_type": "PA Glass"}],
  761. {"default": 60, "PLA": 60, "PA": 20},
  762. 60,
  763. )
  764. assert result == 20
  765. def test_unknown_type_uses_default_key(self):
  766. """Tray type not in the map falls back to the 'default' key, not the
  767. caller fallback. Lets the user tune unknown-filament behavior."""
  768. result = PrintScheduler.resolve_humidity_threshold(
  769. [{"tray_type": "EXOTIC_WOOD"}],
  770. {"default": 40, "PLA": 60},
  771. 999,
  772. )
  773. assert result == 40
  774. def test_empty_tray_slots_skipped(self):
  775. """Empty tray_type strings (unloaded slots) contribute no constraint."""
  776. result = PrintScheduler.resolve_humidity_threshold(
  777. [{"tray_type": ""}, {"tray_type": "PLA"}],
  778. {"default": 30, "PLA": 50},
  779. 60,
  780. )
  781. assert result == 50
  782. def test_all_empty_trays_uses_default_key(self):
  783. """No loaded trays at all → falls back to default key (or fallback if
  784. no overrides). Matches the empty-AMS behavior of the existing alarm
  785. site so an empty AMS still alarms at the user's default rate."""
  786. result = PrintScheduler.resolve_humidity_threshold(
  787. [{"tray_type": ""}, {}],
  788. {"default": 30, "PLA": 50},
  789. 60,
  790. )
  791. assert result == 30
  792. def test_filament_name_normalized(self):
  793. """Tray types like 'PLA Basic', 'pla basic' all normalize to 'PLA'."""
  794. result = PrintScheduler.resolve_humidity_threshold(
  795. [{"tray_type": "pla basic"}],
  796. {"default": 60, "PLA": 25},
  797. 60,
  798. )
  799. assert result == 25
  800. def test_no_tray_type_field_skipped(self):
  801. """Missing tray_type field is treated as empty (unloaded)."""
  802. result = PrintScheduler.resolve_humidity_threshold(
  803. [{}, {"tray_type": "ASA"}],
  804. {"default": 60, "ASA": 30},
  805. 60,
  806. )
  807. assert result == 30
  808. class TestGetHumidityThresholds:
  809. """The DB-loading helper for ``ams_humidity_thresholds`` (#1605)."""
  810. @pytest.fixture
  811. def scheduler(self):
  812. return PrintScheduler()
  813. @pytest.mark.asyncio
  814. async def test_missing_setting_returns_empty(self, scheduler):
  815. db = AsyncMock()
  816. db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))
  817. result = await scheduler._get_humidity_thresholds(db)
  818. assert result == {}
  819. @pytest.mark.asyncio
  820. async def test_empty_value_returns_empty(self, scheduler):
  821. db = AsyncMock()
  822. setting = MagicMock(value="")
  823. db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
  824. result = await scheduler._get_humidity_thresholds(db)
  825. assert result == {}
  826. @pytest.mark.asyncio
  827. async def test_invalid_json_returns_empty(self, scheduler):
  828. db = AsyncMock()
  829. setting = MagicMock(value="not json{")
  830. db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
  831. result = await scheduler._get_humidity_thresholds(db)
  832. assert result == {}
  833. @pytest.mark.asyncio
  834. async def test_valid_json_normalizes_keys(self, scheduler):
  835. """Filament-type keys uppercase; 'default' preserved."""
  836. db = AsyncMock()
  837. setting = MagicMock(value='{"default": 60, "pla": 50, "ASA": 30, "garbage": "x"}')
  838. db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
  839. result = await scheduler._get_humidity_thresholds(db)
  840. assert result == {"default": 60, "PLA": 50, "ASA": 30}
  841. class TestMidPrintDrying(_DryingTestBase):
  842. """Tests for the print_drying_enabled path — drying that runs CONCURRENTLY
  843. with an active print on capable hardware (H2D / H2C / H2S / P2S / X2D / X1C /
  844. A2L / H2D Pro on recent firmware). Distinct from idle drying.
  845. Verifies:
  846. - With the toggle ON and capable hardware, a printer in the busy set is
  847. still evaluated and drying fires at the capped temperature.
  848. - The temperature cap is max(40, preset_temp - 5) — protects spools.
  849. - With the toggle OFF, the existing busy-printer skip still applies.
  850. - With the toggle ON but unsupported firmware, the busy-printer skip
  851. still applies (gated by supports_drying_while_printing).
  852. """
  853. @pytest.fixture
  854. def scheduler(self):
  855. return PrintScheduler()
  856. @staticmethod
  857. def _ams_unit(humidity: str = "75"):
  858. return {
  859. "id": 0,
  860. "module_type": "n3f",
  861. "dry_time": 0,
  862. "humidity_raw": humidity,
  863. "dry_sf_reason": [],
  864. "tray": [{"tray_type": "PLA"}],
  865. }
  866. def _state(self, firmware: str):
  867. state = MagicMock()
  868. state.raw_data = {"ams": [self._ams_unit()]}
  869. state.firmware_version = firmware
  870. return state
  871. @pytest.mark.asyncio
  872. @patch("backend.app.services.print_scheduler.printer_manager")
  873. async def test_running_printer_dries_when_enabled_and_capable(self, mock_pm, scheduler):
  874. """Toggle ON + capable hardware: running printer dries at capped temp."""
  875. mock_pm.get_status.return_value = self._state("01.03.00.00")
  876. mock_pm.is_connected.return_value = True
  877. mock_pm.get_model.return_value = "H2D"
  878. mock_pm.send_drying_command.return_value = True
  879. scheduler._is_printer_idle = MagicMock(return_value=False)
  880. db = AsyncMock()
  881. settings_returns = {
  882. "queue_drying_enabled": self._make_setting("true"),
  883. "ambient_drying_enabled": self._make_setting("false"),
  884. "print_drying_enabled": self._make_setting("true"),
  885. "ams_humidity_fair": self._make_setting("60"),
  886. "queue_drying_block": self._make_setting("false"),
  887. "drying_presets": None,
  888. }
  889. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  890. # Printer 1 is in busy_printers — would normally be skipped
  891. await scheduler._check_auto_drying(db, [], {1})
  892. # PLA preset is 45 degC for n3f; mid-print cap is max(40, 45-5) = 40
  893. mock_pm.send_drying_command.assert_called_once_with(1, 0, 40, 12, mode=1, filament="PLA")
  894. assert 1 in scheduler._drying_in_progress
  895. @pytest.mark.asyncio
  896. @patch("backend.app.services.print_scheduler.printer_manager")
  897. async def test_temp_cap_applied_above_floor(self, mock_pm, scheduler):
  898. """Higher-temp filament (PETG n3f=65) caps to 60, not floor."""
  899. state = MagicMock()
  900. state.raw_data = {
  901. "ams": [
  902. {
  903. "id": 0,
  904. "module_type": "n3f",
  905. "dry_time": 0,
  906. "humidity_raw": "75",
  907. "dry_sf_reason": [],
  908. "tray": [{"tray_type": "PETG"}],
  909. }
  910. ]
  911. }
  912. state.firmware_version = "01.03.00.00"
  913. mock_pm.get_status.return_value = state
  914. mock_pm.is_connected.return_value = True
  915. mock_pm.get_model.return_value = "H2D"
  916. mock_pm.send_drying_command.return_value = True
  917. scheduler._is_printer_idle = MagicMock(return_value=False)
  918. db = AsyncMock()
  919. settings_returns = {
  920. "queue_drying_enabled": self._make_setting("true"),
  921. "ambient_drying_enabled": self._make_setting("false"),
  922. "print_drying_enabled": self._make_setting("true"),
  923. "ams_humidity_fair": self._make_setting("60"),
  924. "queue_drying_block": self._make_setting("false"),
  925. "drying_presets": None,
  926. }
  927. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  928. await scheduler._check_auto_drying(db, [], {1})
  929. # PETG preset 65 -> max(40, 65-5) = 60
  930. mock_pm.send_drying_command.assert_called_once_with(1, 0, 60, 12, mode=1, filament="PETG")
  931. @pytest.mark.asyncio
  932. @patch("backend.app.services.print_scheduler.printer_manager")
  933. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  934. async def test_running_printer_skipped_when_toggle_off(self, mock_sd, mock_pm, scheduler):
  935. """Toggle OFF: running printer is skipped even on capable hardware."""
  936. mock_pm.get_status.return_value = self._state("01.03.00.00")
  937. mock_pm.is_connected.return_value = True
  938. mock_pm.get_model.return_value = "H2D"
  939. scheduler._is_printer_idle = MagicMock(return_value=False)
  940. db = AsyncMock()
  941. settings_returns = {
  942. "queue_drying_enabled": self._make_setting("true"),
  943. "ambient_drying_enabled": self._make_setting("false"),
  944. "print_drying_enabled": self._make_setting("false"),
  945. "ams_humidity_fair": self._make_setting("60"),
  946. "queue_drying_block": self._make_setting("false"),
  947. "drying_presets": None,
  948. }
  949. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  950. await scheduler._check_auto_drying(db, [], {1})
  951. mock_pm.send_drying_command.assert_not_called()
  952. @pytest.mark.asyncio
  953. @patch("backend.app.services.print_scheduler.printer_manager")
  954. async def test_running_printer_skipped_when_firmware_too_old(self, mock_pm, scheduler):
  955. """Toggle ON but firmware below matrix threshold: skip."""
  956. # H2D matrix minimum is 01.03.00.00; this is below
  957. mock_pm.get_status.return_value = self._state("01.02.30.00")
  958. mock_pm.is_connected.return_value = True
  959. mock_pm.get_model.return_value = "H2D"
  960. scheduler._is_printer_idle = MagicMock(return_value=False)
  961. db = AsyncMock()
  962. settings_returns = {
  963. "queue_drying_enabled": self._make_setting("true"),
  964. "ambient_drying_enabled": self._make_setting("false"),
  965. "print_drying_enabled": self._make_setting("true"),
  966. "ams_humidity_fair": self._make_setting("60"),
  967. "queue_drying_block": self._make_setting("false"),
  968. "drying_presets": None,
  969. }
  970. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  971. await scheduler._check_auto_drying(db, [], {1})
  972. mock_pm.send_drying_command.assert_not_called()
  973. @pytest.mark.asyncio
  974. @patch("backend.app.services.print_scheduler.printer_manager")
  975. async def test_running_printer_skipped_when_model_excluded(self, mock_pm, scheduler):
  976. """Toggle ON, recent firmware, but excluded model (A1): skip."""
  977. mock_pm.get_status.return_value = self._state("99.99.99.99")
  978. mock_pm.is_connected.return_value = True
  979. mock_pm.get_model.return_value = "A1"
  980. scheduler._is_printer_idle = MagicMock(return_value=False)
  981. db = AsyncMock()
  982. settings_returns = {
  983. "queue_drying_enabled": self._make_setting("true"),
  984. "ambient_drying_enabled": self._make_setting("false"),
  985. "print_drying_enabled": self._make_setting("true"),
  986. "ams_humidity_fair": self._make_setting("60"),
  987. "queue_drying_block": self._make_setting("false"),
  988. "drying_presets": None,
  989. }
  990. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  991. await scheduler._check_auto_drying(db, [], {1})
  992. mock_pm.send_drying_command.assert_not_called()