| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007 |
- """Tests for the auto-drying feature in the print scheduler.
- Covers:
- - Conservative drying parameter selection (mixed filaments)
- - Drying preset loading (user-configured vs defaults)
- - Auto-drying lifecycle: start, humidity stop, minimum drying time
- - Auto-drying stop conditions: feature disabled, no scheduled items, per-printer
- - Sync drying state after restart
- """
- import time
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.services.print_scheduler import PrintScheduler
- class TestConservativeDryingParams:
- """Test _get_conservative_drying_params — picks safest temp/duration for mixed filaments."""
- @pytest.fixture
- def scheduler(self):
- return PrintScheduler()
- def test_single_filament_pla(self, scheduler):
- """Single PLA tray uses PLA preset."""
- trays = [{"tray_type": "PLA"}]
- presets = PrintScheduler.DEFAULT_DRYING_PRESETS
- result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
- assert result == (45, 12, "PLA")
- def test_mixed_filaments_lowest_temp(self, scheduler):
- """Mixed PLA + ABS: should use PLA's 45°C (lowest), ABS's 12h (longest for n3f)."""
- trays = [{"tray_type": "PLA"}, {"tray_type": "ABS"}]
- presets = PrintScheduler.DEFAULT_DRYING_PRESETS
- result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
- temp, hours, _ = result
- assert temp == 45 # PLA is lowest
- assert hours == 12
- def test_mixed_filaments_longest_duration(self, scheduler):
- """Mixed ABS (8h) + PVA (18h) on n3s: should use longest duration."""
- trays = [{"tray_type": "ABS"}, {"tray_type": "PVA"}]
- presets = PrintScheduler.DEFAULT_DRYING_PRESETS
- result = scheduler._get_conservative_drying_params(trays, "n3s", presets)
- temp, hours, _ = result
- assert temp == 80 # ABS n3s=80, PVA n3s=85 → lowest=80
- assert hours == 18 # ABS n3s_hours=8, PVA n3s_hours=18 → longest=18
- def test_empty_trays_returns_none(self, scheduler):
- """No loaded trays returns None."""
- result = scheduler._get_conservative_drying_params([], "n3f", PrintScheduler.DEFAULT_DRYING_PRESETS)
- assert result is None
- def test_unknown_filament_skipped(self, scheduler):
- """Unknown filament types are ignored."""
- trays = [{"tray_type": "EXOTIC_WOOD"}]
- result = scheduler._get_conservative_drying_params(trays, "n3f", PrintScheduler.DEFAULT_DRYING_PRESETS)
- assert result is None
- def test_filament_type_normalization(self, scheduler):
- """'PLA Basic' should normalize to 'PLA'."""
- trays = [{"tray_type": "PLA Basic"}]
- presets = PrintScheduler.DEFAULT_DRYING_PRESETS
- result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
- assert result is not None
- assert result[0] == 45 # PLA temp
- def test_empty_tray_type_skipped(self, scheduler):
- """Trays with empty tray_type are skipped."""
- trays = [{"tray_type": ""}, {"tray_type": "PETG"}]
- presets = PrintScheduler.DEFAULT_DRYING_PRESETS
- result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
- assert result is not None
- assert result[2] == "PETG"
- def test_n3s_uses_n3s_keys(self, scheduler):
- """AMS-HT (n3s) should use n3s temp and n3s_hours."""
- trays = [{"tray_type": "TPU"}]
- presets = PrintScheduler.DEFAULT_DRYING_PRESETS
- result = scheduler._get_conservative_drying_params(trays, "n3s", presets)
- assert result == (75, 18, "TPU") # n3s=75, n3s_hours=18
- def test_n3f_uses_n3f_keys(self, scheduler):
- """AMS 2 Pro (n3f) should use n3f temp and n3f_hours."""
- trays = [{"tray_type": "TPU"}]
- presets = PrintScheduler.DEFAULT_DRYING_PRESETS
- result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
- assert result == (65, 12, "TPU") # n3f=65, n3f_hours=12
- def test_custom_presets(self, scheduler):
- """Custom presets override defaults."""
- trays = [{"tray_type": "PLA"}]
- custom = {"PLA": {"n3f": 50, "n3s": 50, "n3f_hours": 6, "n3s_hours": 6}}
- result = scheduler._get_conservative_drying_params(trays, "n3f", custom)
- assert result == (50, 6, "PLA")
- class TestDryingPresets:
- """Test _get_drying_presets — loads user presets from DB or falls back to defaults."""
- @pytest.fixture
- def scheduler(self):
- return PrintScheduler()
- @pytest.mark.asyncio
- async def test_default_presets_when_no_setting(self, scheduler):
- """Returns built-in defaults when no DB setting exists."""
- db = AsyncMock()
- result_mock = MagicMock()
- result_mock.scalar_one_or_none.return_value = None
- db.execute = AsyncMock(return_value=result_mock)
- presets = await scheduler._get_drying_presets(db)
- assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
- @pytest.mark.asyncio
- async def test_user_presets_from_db(self, scheduler):
- """Returns user-configured presets when saved in DB."""
- db = AsyncMock()
- setting = MagicMock()
- setting.value = '{"PLA": {"n3f": 50, "n3s": 50, "n3f_hours": 6, "n3s_hours": 6}}'
- result_mock = MagicMock()
- result_mock.scalar_one_or_none.return_value = setting
- db.execute = AsyncMock(return_value=result_mock)
- presets = await scheduler._get_drying_presets(db)
- assert presets["PLA"]["n3f"] == 50
- @pytest.mark.asyncio
- async def test_invalid_json_falls_back(self, scheduler):
- """Invalid JSON in DB falls back to defaults."""
- db = AsyncMock()
- setting = MagicMock()
- setting.value = "not valid json{{"
- result_mock = MagicMock()
- result_mock.scalar_one_or_none.return_value = setting
- db.execute = AsyncMock(return_value=result_mock)
- presets = await scheduler._get_drying_presets(db)
- assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
- @pytest.mark.asyncio
- async def test_empty_string_falls_back(self, scheduler):
- """Empty string in DB falls back to defaults."""
- db = AsyncMock()
- setting = MagicMock()
- setting.value = ""
- result_mock = MagicMock()
- result_mock.scalar_one_or_none.return_value = setting
- db.execute = AsyncMock(return_value=result_mock)
- presets = await scheduler._get_drying_presets(db)
- assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
- class TestSyncDryingState:
- """Test _sync_drying_state — syncs in-memory state with actual printer status."""
- @pytest.fixture
- def scheduler(self):
- return PrintScheduler()
- @patch("backend.app.services.print_scheduler.printer_manager")
- def test_removes_stopped_printers(self, mock_pm, scheduler):
- """Printers that stopped drying are removed from tracking."""
- scheduler._drying_in_progress = {1: time.monotonic()}
- state = MagicMock()
- state.raw_data = {"ams": [{"dry_time": 0}]}
- mock_pm.get_status.return_value = state
- scheduler._sync_drying_state()
- assert 1 not in scheduler._drying_in_progress
- @patch("backend.app.services.print_scheduler.printer_manager")
- def test_keeps_active_printers(self, mock_pm, scheduler):
- """Printers still drying remain in tracking."""
- ts = time.monotonic()
- scheduler._drying_in_progress = {1: ts}
- state = MagicMock()
- state.raw_data = {"ams": [{"dry_time": 120}]}
- mock_pm.get_status.return_value = state
- scheduler._sync_drying_state()
- assert scheduler._drying_in_progress[1] == ts
- @patch("backend.app.services.print_scheduler.printer_manager")
- def test_removes_disconnected_printers(self, mock_pm, scheduler):
- """Disconnected printers are removed from tracking."""
- scheduler._drying_in_progress = {1: time.monotonic()}
- mock_pm.get_status.return_value = None
- scheduler._sync_drying_state()
- assert 1 not in scheduler._drying_in_progress
- class TestStopDrying:
- """Test _stop_drying — sends stop commands and clears tracking."""
- @pytest.fixture
- def scheduler(self):
- return PrintScheduler()
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- async def test_stops_all_ams_units(self, mock_pm, scheduler):
- """Sends stop command to each AMS unit that is drying."""
- scheduler._drying_in_progress = {1: time.monotonic()}
- state = MagicMock()
- state.raw_data = {
- "ams": [
- {"id": 0, "dry_time": 120},
- {"id": 1, "dry_time": 0},
- {"id": 128, "dry_time": 60},
- ]
- }
- mock_pm.get_status.return_value = state
- await scheduler._stop_drying(1)
- # Should send stop to AMS 0 and 128, not AMS 1
- calls = mock_pm.send_drying_command.call_args_list
- assert len(calls) == 2
- assert calls[0].args == (1, 0, 0, 0)
- assert calls[1].args == (1, 128, 0, 0)
- assert 1 not in scheduler._drying_in_progress
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- async def test_clears_tracking_when_no_state(self, mock_pm, scheduler):
- """Clears tracking when printer has no state (disconnected)."""
- scheduler._drying_in_progress = {1: time.monotonic()}
- mock_pm.get_status.return_value = None
- await scheduler._stop_drying(1)
- assert 1 not in scheduler._drying_in_progress
- class TestMinimumDryingTime:
- """Regression: drying should not stop/restart rapidly when humidity oscillates near threshold."""
- @pytest.fixture
- def scheduler(self):
- s = PrintScheduler()
- s._min_drying_seconds = 1800 # 30 minutes
- return s
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
- async def test_no_stop_before_minimum_time(self, mock_sd, mock_pm, scheduler):
- """Drying should NOT stop when humidity drops below threshold before 30 min."""
- # Simulate: drying started 5 minutes ago
- scheduler._drying_in_progress = {1: time.monotonic() - 300}
- state = MagicMock()
- state.raw_data = {
- "ams": [
- {
- "id": 0,
- "module_type": "n3f",
- "dry_time": 600,
- "humidity_raw": "18",
- "dry_sf_reason": [],
- "tray": [{"tray_type": "PLA"}],
- }
- ]
- }
- state.firmware_version = "01.09.00.00"
- mock_pm.get_status.return_value = state
- mock_pm.is_connected.return_value = True
- mock_pm.get_model.return_value = "X1C"
- # Mock _is_printer_idle and DB
- scheduler._is_printer_idle = MagicMock(return_value=True)
- db = AsyncMock()
- # Mock settings: enabled, threshold=21
- settings_returns = {
- "queue_drying_enabled": self._make_setting("true"),
- "ams_humidity_fair": self._make_setting("21"),
- "queue_drying_block": self._make_setting("false"),
- "drying_presets": None,
- }
- db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))
- # Queue item with schedule
- item = MagicMock()
- item.printer_id = 1
- item.scheduled_time = MagicMock() # Has a schedule
- item.manual_start = False
- await scheduler._check_auto_drying(db, [item], set())
- # Should NOT have sent stop command via humidity check — minimum time not elapsed
- # The only calls should NOT include the humidity-based stop
- for call in mock_pm.send_drying_command.call_args_list:
- # If any stop was called, it should NOT be from the humidity path
- # (humidity path uses keyword args: temp=0, duration=0, mode=0)
- assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
- "Humidity-based stop should not fire before minimum drying time"
- )
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
- async def test_stops_after_minimum_time(self, mock_sd, mock_pm, scheduler):
- """Drying SHOULD stop when humidity below threshold AND 30 min elapsed."""
- # Simulate: drying started 35 minutes ago
- scheduler._drying_in_progress = {1: time.monotonic() - 2100}
- state = MagicMock()
- state.raw_data = {
- "ams": [
- {
- "id": 0,
- "module_type": "n3f",
- "dry_time": 600,
- "humidity_raw": "18",
- "dry_sf_reason": [],
- "tray": [{"tray_type": "PLA"}],
- }
- ]
- }
- state.firmware_version = "01.09.00.00"
- mock_pm.get_status.return_value = state
- mock_pm.is_connected.return_value = True
- mock_pm.get_model.return_value = "X1C"
- scheduler._is_printer_idle = MagicMock(return_value=True)
- db = AsyncMock()
- settings_returns = {
- "queue_drying_enabled": self._make_setting("true"),
- "ams_humidity_fair": self._make_setting("21"),
- "queue_drying_block": self._make_setting("false"),
- "drying_presets": None,
- }
- db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))
- item = MagicMock()
- item.printer_id = 1
- item.scheduled_time = MagicMock()
- item.manual_start = False
- await scheduler._check_auto_drying(db, [item], set())
- # Should have sent stop command (humidity-based stop after minimum time)
- mock_pm.send_drying_command.assert_any_call(1, 0, temp=0, duration=0, mode=0)
- @staticmethod
- def _make_setting(value):
- s = MagicMock()
- s.value = value
- return s
- @staticmethod
- def _make_db_side_effect(settings_map, printer_id=1):
- """Create a side_effect for db.execute that returns settings and printers."""
- async def side_effect(stmt):
- result = MagicMock()
- stmt_str = str(stmt)
- # Extract bind parameter values (SQLAlchemy uses :key_1 placeholders)
- try:
- compiled = stmt.compile(compile_kwargs={"literal_binds": False})
- param_values = list(compiled.params.values())
- except Exception:
- param_values = []
- # Match settings queries by checking bind parameter values
- matched = False
- for key, val in settings_map.items():
- if key in param_values:
- result.scalar_one_or_none.return_value = val
- matched = True
- break
- if not matched:
- if "printer" in stmt_str.lower() or "is_active" in stmt_str:
- printer = MagicMock()
- printer.id = printer_id
- printer.is_active = True
- scalars_mock = MagicMock()
- scalars_mock.__iter__ = MagicMock(return_value=iter([printer]))
- result.scalars.return_value = scalars_mock
- else:
- result.scalar_one_or_none.return_value = None
- return result
- return side_effect
- class TestAutoStopOnFeatureDisabled:
- """Regression: disabling auto-drying in settings should stop active drying sessions."""
- @pytest.fixture
- def scheduler(self):
- return PrintScheduler()
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- async def test_stops_drying_when_disabled(self, mock_pm, scheduler):
- """Disabling auto-drying should send stop commands to all drying printers."""
- scheduler._drying_in_progress = {1: time.monotonic(), 2: time.monotonic()}
- # Printer 1: drying, Printer 2: drying
- def get_status(pid):
- state = MagicMock()
- state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
- return state
- mock_pm.get_status.side_effect = get_status
- db = AsyncMock()
- # queue_drying_enabled = false
- setting = MagicMock()
- setting.value = "false"
- result_mock = MagicMock()
- result_mock.scalar_one_or_none.return_value = setting
- db.execute = AsyncMock(return_value=result_mock)
- await scheduler._check_auto_drying(db, [], set())
- # Should have sent stop commands
- assert mock_pm.send_drying_command.call_count == 2
- assert not scheduler._drying_in_progress
- class TestAutoStopOnNoScheduledItems:
- """Regression: removing scheduled items should stop auto-drying."""
- @pytest.fixture
- def scheduler(self):
- return PrintScheduler()
- @staticmethod
- def _make_setting(value):
- s = MagicMock()
- s.value = value
- return s
- @staticmethod
- def _make_db_side_effect(settings_map):
- """Create a side_effect for db.execute that returns settings by key."""
- async def side_effect(stmt):
- result = MagicMock()
- try:
- compiled = stmt.compile(compile_kwargs={"literal_binds": False})
- param_values = list(compiled.params.values())
- except Exception:
- param_values = []
- for key, val in settings_map.items():
- if key in param_values:
- result.scalar_one_or_none.return_value = val
- return result
- result.scalar_one_or_none.return_value = None
- return result
- return side_effect
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- async def test_stops_when_no_scheduled_items(self, mock_pm, scheduler):
- """Auto-drying stops when queue has no scheduled items (queue mode only)."""
- scheduler._drying_in_progress = {1: time.monotonic()}
- state = MagicMock()
- state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
- mock_pm.get_status.return_value = state
- db = AsyncMock()
- settings_returns = {
- "queue_drying_enabled": self._make_setting("true"),
- "ambient_drying_enabled": self._make_setting("false"),
- }
- db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
- # Manual-start items only (no scheduled_time)
- item = MagicMock()
- item.printer_id = 1
- item.scheduled_time = None
- item.manual_start = True
- await scheduler._check_auto_drying(db, [item], set())
- # Should have stopped drying
- assert mock_pm.send_drying_command.called
- assert not scheduler._drying_in_progress
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- async def test_stops_when_empty_queue(self, mock_pm, scheduler):
- """Auto-drying stops when queue is completely empty (queue mode only)."""
- scheduler._drying_in_progress = {1: time.monotonic()}
- state = MagicMock()
- state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
- mock_pm.get_status.return_value = state
- db = AsyncMock()
- settings_returns = {
- "queue_drying_enabled": self._make_setting("true"),
- "ambient_drying_enabled": self._make_setting("false"),
- }
- db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
- await scheduler._check_auto_drying(db, [], set())
- assert mock_pm.send_drying_command.called
- assert not scheduler._drying_in_progress
- class TestDryingTrackingTimestamps:
- """Test that _drying_in_progress uses timestamps, not booleans."""
- def test_initial_state_empty(self):
- """Fresh scheduler has no drying tracked."""
- scheduler = PrintScheduler()
- assert scheduler._drying_in_progress == {}
- def test_timestamp_is_monotonic(self):
- """Tracked values should be monotonic timestamps."""
- scheduler = PrintScheduler()
- before = time.monotonic()
- scheduler._drying_in_progress[1] = time.monotonic()
- after = time.monotonic()
- assert before <= scheduler._drying_in_progress[1] <= after
- def test_timestamp_is_truthy(self):
- """Timestamps are truthy for .get() checks (backward compat with bool pattern)."""
- scheduler = PrintScheduler()
- scheduler._drying_in_progress[1] = time.monotonic()
- assert scheduler._drying_in_progress.get(1)
- assert not scheduler._drying_in_progress.get(999)
- class _DryingTestBase:
- """Shared helpers for auto-drying integration tests."""
- @staticmethod
- def _make_setting(value):
- s = MagicMock()
- s.value = value
- return s
- @staticmethod
- def _make_db_side_effect(settings_map, printer_ids=None):
- """Create a side_effect for db.execute that returns settings by key and printers."""
- if printer_ids is None:
- printer_ids = [1]
- async def side_effect(stmt):
- result = MagicMock()
- stmt_str = str(stmt)
- try:
- compiled = stmt.compile(compile_kwargs={"literal_binds": False})
- param_values = list(compiled.params.values())
- except Exception:
- param_values = []
- for key, val in settings_map.items():
- if key in param_values:
- result.scalar_one_or_none.return_value = val
- return result
- if "printer" in stmt_str.lower() or "is_active" in stmt_str:
- printers = []
- for pid in printer_ids:
- p = MagicMock()
- p.id = pid
- p.is_active = True
- printers.append(p)
- scalars_mock = MagicMock()
- scalars_mock.__iter__ = MagicMock(return_value=iter(printers))
- result.scalars.return_value = scalars_mock
- else:
- result.scalar_one_or_none.return_value = None
- return result
- return side_effect
- class TestAmbientDrying(_DryingTestBase):
- """Tests for ambient drying mode — drying based on humidity regardless of queue state."""
- @pytest.fixture
- def scheduler(self):
- return PrintScheduler()
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
- async def test_ambient_dries_idle_printer_without_queue(self, mock_sd, mock_pm, scheduler):
- """Ambient mode starts drying on idle printers even with no queue items."""
- state = MagicMock()
- state.raw_data = {
- "ams": [
- {
- "id": 0,
- "module_type": "n3f",
- "dry_time": 0,
- "humidity_raw": "75",
- "dry_sf_reason": [],
- "tray": [{"tray_type": "PLA"}],
- }
- ]
- }
- state.firmware_version = "01.09.00.00"
- mock_pm.get_status.return_value = state
- mock_pm.is_connected.return_value = True
- mock_pm.get_model.return_value = "X1C"
- mock_pm.send_drying_command.return_value = True
- scheduler._is_printer_idle = MagicMock(return_value=True)
- db = AsyncMock()
- settings_returns = {
- "queue_drying_enabled": self._make_setting("false"),
- "ambient_drying_enabled": self._make_setting("true"),
- "ams_humidity_fair": self._make_setting("60"),
- "queue_drying_block": self._make_setting("false"),
- "drying_presets": None,
- }
- db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
- # Empty queue — ambient mode should still dry
- await scheduler._check_auto_drying(db, [], set())
- mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
- assert 1 in scheduler._drying_in_progress
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
- async def test_ambient_does_not_dry_below_threshold(self, mock_sd, mock_pm, scheduler):
- """Ambient mode does NOT dry when humidity is below threshold."""
- state = MagicMock()
- state.raw_data = {
- "ams": [
- {
- "id": 0,
- "module_type": "n3f",
- "dry_time": 0,
- "humidity_raw": "40",
- "dry_sf_reason": [],
- "tray": [{"tray_type": "PLA"}],
- }
- ]
- }
- state.firmware_version = "01.09.00.00"
- mock_pm.get_status.return_value = state
- mock_pm.is_connected.return_value = True
- mock_pm.get_model.return_value = "X1C"
- scheduler._is_printer_idle = MagicMock(return_value=True)
- db = AsyncMock()
- settings_returns = {
- "queue_drying_enabled": self._make_setting("false"),
- "ambient_drying_enabled": self._make_setting("true"),
- "ams_humidity_fair": self._make_setting("60"),
- "queue_drying_block": self._make_setting("false"),
- "drying_presets": None,
- }
- db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
- await scheduler._check_auto_drying(db, [], set())
- mock_pm.send_drying_command.assert_not_called()
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- async def test_ambient_off_stops_drying_without_queue(self, mock_pm, scheduler):
- """Disabling ambient drying stops drying on printers without queue items."""
- scheduler._drying_in_progress = {1: time.monotonic()}
- state = MagicMock()
- state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
- mock_pm.get_status.return_value = state
- db = AsyncMock()
- settings_returns = {
- "queue_drying_enabled": self._make_setting("false"),
- "ambient_drying_enabled": self._make_setting("false"),
- }
- db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
- await scheduler._check_auto_drying(db, [], set())
- assert mock_pm.send_drying_command.called
- assert not scheduler._drying_in_progress
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
- async def test_ambient_continues_when_queue_empty(self, mock_sd, mock_pm, scheduler):
- """Ambient drying continues even when queue has no scheduled items (unlike queue mode)."""
- scheduler._drying_in_progress = {1: time.monotonic() - 100}
- state = MagicMock()
- state.raw_data = {
- "ams": [
- {
- "id": 0,
- "module_type": "n3f",
- "dry_time": 600,
- "humidity_raw": "75",
- "dry_sf_reason": [],
- "tray": [{"tray_type": "PLA"}],
- }
- ]
- }
- state.firmware_version = "01.09.00.00"
- mock_pm.get_status.return_value = state
- mock_pm.is_connected.return_value = True
- mock_pm.get_model.return_value = "X1C"
- scheduler._is_printer_idle = MagicMock(return_value=True)
- db = AsyncMock()
- settings_returns = {
- "queue_drying_enabled": self._make_setting("false"),
- "ambient_drying_enabled": self._make_setting("true"),
- "ams_humidity_fair": self._make_setting("60"),
- "queue_drying_block": self._make_setting("false"),
- "drying_presets": None,
- }
- db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
- await scheduler._check_auto_drying(db, [], set())
- # Should NOT have sent stop — humidity still high, drying continues
- for call in mock_pm.send_drying_command.call_args_list:
- assert call.kwargs.get("mode") != 0, "Should not stop drying in ambient mode with high humidity"
- assert 1 in scheduler._drying_in_progress
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
- async def test_queue_only_does_not_dry_without_scheduled_items(self, mock_sd, mock_pm, scheduler):
- """Queue mode alone does NOT dry printers that have no scheduled queue items."""
- state = MagicMock()
- state.raw_data = {
- "ams": [
- {
- "id": 0,
- "module_type": "n3f",
- "dry_time": 0,
- "humidity_raw": "75",
- "dry_sf_reason": [],
- "tray": [{"tray_type": "PLA"}],
- }
- ]
- }
- state.firmware_version = "01.09.00.00"
- mock_pm.get_status.return_value = state
- mock_pm.is_connected.return_value = True
- mock_pm.get_model.return_value = "X1C"
- scheduler._is_printer_idle = MagicMock(return_value=True)
- db = AsyncMock()
- settings_returns = {
- "queue_drying_enabled": self._make_setting("true"),
- "ambient_drying_enabled": self._make_setting("false"),
- "ams_humidity_fair": self._make_setting("60"),
- "queue_drying_block": self._make_setting("false"),
- "drying_presets": None,
- }
- db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
- # No queue items at all
- await scheduler._check_auto_drying(db, [], set())
- mock_pm.send_drying_command.assert_not_called()
- class TestBlockForDryingBugFix(_DryingTestBase):
- """Regression: block mode should not skip humidity auto-stop for already-drying printers."""
- @pytest.fixture
- def scheduler(self):
- s = PrintScheduler()
- s._min_drying_seconds = 1800
- return s
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
- async def test_block_mode_allows_humidity_stop_for_active_drying(self, mock_sd, mock_pm, scheduler):
- """Bug fix: printer already drying in block mode should still check humidity to auto-stop."""
- # Drying started 35 minutes ago
- scheduler._drying_in_progress = {1: time.monotonic() - 2100}
- state = MagicMock()
- state.raw_data = {
- "ams": [
- {
- "id": 0,
- "module_type": "n3f",
- "dry_time": 600,
- "humidity_raw": "30", # Below threshold
- "dry_sf_reason": [],
- "tray": [{"tray_type": "PLA"}],
- }
- ]
- }
- state.firmware_version = "01.09.00.00"
- mock_pm.get_status.return_value = state
- mock_pm.is_connected.return_value = True
- mock_pm.get_model.return_value = "X1C"
- scheduler._is_printer_idle = MagicMock(return_value=True)
- db = AsyncMock()
- settings_returns = {
- "queue_drying_enabled": self._make_setting("true"),
- "ambient_drying_enabled": self._make_setting("false"),
- "ams_humidity_fair": self._make_setting("60"),
- "queue_drying_block": self._make_setting("true"),
- "drying_presets": None,
- }
- db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
- # Queue item exists for this printer (triggers block mode gate)
- item = MagicMock()
- item.printer_id = 1
- item.scheduled_time = MagicMock()
- item.manual_start = False
- await scheduler._check_auto_drying(db, [item], set())
- # Should have sent stop command — humidity dropped below threshold after 30+ min
- mock_pm.send_drying_command.assert_any_call(1, 0, temp=0, duration=0, mode=0)
- @pytest.mark.asyncio
- @patch("backend.app.services.print_scheduler.printer_manager")
- @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
- async def test_block_mode_prevents_new_drying_start(self, mock_sd, mock_pm, scheduler):
- """Block mode should still prevent starting NEW drying on printers with pending items."""
- state = MagicMock()
- state.raw_data = {
- "ams": [
- {
- "id": 0,
- "module_type": "n3f",
- "dry_time": 0,
- "humidity_raw": "75",
- "dry_sf_reason": [],
- "tray": [{"tray_type": "PLA"}],
- }
- ]
- }
- state.firmware_version = "01.09.00.00"
- mock_pm.get_status.return_value = state
- mock_pm.is_connected.return_value = True
- mock_pm.get_model.return_value = "X1C"
- scheduler._is_printer_idle = MagicMock(return_value=True)
- db = AsyncMock()
- settings_returns = {
- "queue_drying_enabled": self._make_setting("true"),
- "ambient_drying_enabled": self._make_setting("false"),
- "ams_humidity_fair": self._make_setting("60"),
- "queue_drying_block": self._make_setting("true"),
- "drying_presets": None,
- }
- db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
- item = MagicMock()
- item.printer_id = 1
- item.scheduled_time = MagicMock()
- item.manual_start = False
- await scheduler._check_auto_drying(db, [item], set())
- # Should NOT start drying — block mode with pending items
- mock_pm.send_drying_command.assert_not_called()
- class TestResolveHumidityThreshold:
- """Per-filament humidity threshold resolver (#1605).
- Resolves the trigger threshold for an AMS unit from the loaded tray types.
- Mixed loads use the lowest (most restrictive) value. Empty / unloaded trays
- contribute no constraint; falls back to the global ``ams_humidity_fair``
- when no per-type overrides are configured.
- """
- def test_no_overrides_falls_back_to_global(self):
- """Empty overrides map → caller's global fallback is used verbatim."""
- result = PrintScheduler.resolve_humidity_threshold([{"tray_type": "PLA"}], {}, 60)
- assert result == 60
- def test_single_known_type_uses_override(self):
- """Single PLA tray with override = 50 returns 50."""
- result = PrintScheduler.resolve_humidity_threshold(
- [{"tray_type": "PLA Basic"}],
- {"default": 60, "PLA": 50},
- 60,
- )
- assert result == 50
- def test_mixed_load_picks_lowest(self):
- """Mixed PLA (60) + Nylon (20) → most restrictive = 20."""
- result = PrintScheduler.resolve_humidity_threshold(
- [{"tray_type": "PLA Basic"}, {"tray_type": "PA Glass"}],
- {"default": 60, "PLA": 60, "PA": 20},
- 60,
- )
- assert result == 20
- def test_unknown_type_uses_default_key(self):
- """Tray type not in the map falls back to the 'default' key, not the
- caller fallback. Lets the user tune unknown-filament behavior."""
- result = PrintScheduler.resolve_humidity_threshold(
- [{"tray_type": "EXOTIC_WOOD"}],
- {"default": 40, "PLA": 60},
- 999,
- )
- assert result == 40
- def test_empty_tray_slots_skipped(self):
- """Empty tray_type strings (unloaded slots) contribute no constraint."""
- result = PrintScheduler.resolve_humidity_threshold(
- [{"tray_type": ""}, {"tray_type": "PLA"}],
- {"default": 30, "PLA": 50},
- 60,
- )
- assert result == 50
- def test_all_empty_trays_uses_default_key(self):
- """No loaded trays at all → falls back to default key (or fallback if
- no overrides). Matches the empty-AMS behavior of the existing alarm
- site so an empty AMS still alarms at the user's default rate."""
- result = PrintScheduler.resolve_humidity_threshold(
- [{"tray_type": ""}, {}],
- {"default": 30, "PLA": 50},
- 60,
- )
- assert result == 30
- def test_filament_name_normalized(self):
- """Tray types like 'PLA Basic', 'pla basic' all normalize to 'PLA'."""
- result = PrintScheduler.resolve_humidity_threshold(
- [{"tray_type": "pla basic"}],
- {"default": 60, "PLA": 25},
- 60,
- )
- assert result == 25
- def test_no_tray_type_field_skipped(self):
- """Missing tray_type field is treated as empty (unloaded)."""
- result = PrintScheduler.resolve_humidity_threshold(
- [{}, {"tray_type": "ASA"}],
- {"default": 60, "ASA": 30},
- 60,
- )
- assert result == 30
- class TestGetHumidityThresholds:
- """The DB-loading helper for ``ams_humidity_thresholds`` (#1605)."""
- @pytest.fixture
- def scheduler(self):
- return PrintScheduler()
- @pytest.mark.asyncio
- async def test_missing_setting_returns_empty(self, scheduler):
- db = AsyncMock()
- db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))
- result = await scheduler._get_humidity_thresholds(db)
- assert result == {}
- @pytest.mark.asyncio
- async def test_empty_value_returns_empty(self, scheduler):
- db = AsyncMock()
- setting = MagicMock(value="")
- db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
- result = await scheduler._get_humidity_thresholds(db)
- assert result == {}
- @pytest.mark.asyncio
- async def test_invalid_json_returns_empty(self, scheduler):
- db = AsyncMock()
- setting = MagicMock(value="not json{")
- db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
- result = await scheduler._get_humidity_thresholds(db)
- assert result == {}
- @pytest.mark.asyncio
- async def test_valid_json_normalizes_keys(self, scheduler):
- """Filament-type keys uppercase; 'default' preserved."""
- db = AsyncMock()
- setting = MagicMock(value='{"default": 60, "pla": 50, "ASA": 30, "garbage": "x"}')
- db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
- result = await scheduler._get_humidity_thresholds(db)
- assert result == {"default": 60, "PLA": 50, "ASA": 30}
|