Files
bambuddy/backend/tests/unit/test_scheduler_auto_drying.py
T
maziggy 53ae5fb620 fix(drying): don't stop a running AMS dry on an unreliable humidity re-check (#1892)
Auto-drying stopped manually started (and pre-restart) AMS drying cycles
after exactly 30 minutes. The already-drying branch in _check_auto_drying()
applied a humidity-based auto-stop despite its own "track but don't stop"
comment, and the humidity re-check is unreliable: RH drops steeply in heated
air, so the sensor reads ~15-20% within minutes of the dryer starting even
with saturated filament. humidity <= threshold was thus effectively always
true, and the _min_drying_seconds=1800 floor pinned the stop to the 30-minute
mark. This also truncated Bambuddy's own preset-duration dries.

Remove the humidity-based early-stop entirely: a running dry now runs to its
configured duration (firmware stops it). Scheduling stops (print priority,
queue no longer needing the dry) are unchanged via _stop_drying(). Drop the
now-unused _min_drying_seconds.
2026-07-05 09:32:02 +02:00

1195 lines
47 KiB
Python

"""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 #1892: a running drying cycle must never be stopped by a humidity re-check.
Relative humidity reads low in heated air (the AMS sensor sees ~15-20% within
minutes of the dryer starting even while the filament is still saturated), so a
humidity-based auto-stop would truncate every cycle — manual or Bambuddy-started —
to the old minimum-time floor. Drying is now left to run to its configured
duration; the firmware stops it when the duration elapses.
"""
@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_no_stop_before_minimum_time(self, mock_sd, mock_pm, scheduler):
"""Drying should NOT stop when humidity drops below threshold shortly after start."""
# 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_no_stop_after_long_elapsed_time(self, mock_sd, mock_pm, scheduler):
"""#1892: drying must NOT stop even long after start with low humidity — let it run."""
# Simulate: drying started 35 minutes ago, humidity reads low (heated air)
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())
# Must NOT send a humidity-based stop — drying is left to run to its duration
for call in mock_pm.send_drying_command.call_args_list:
assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
"Humidity re-check must never stop a running drying cycle (#1892)"
)
@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 gates NEW drying starts but must leave running dries alone (#1892)."""
@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_block_mode_leaves_active_drying_running(self, mock_sd, mock_pm, scheduler):
"""#1892: a printer already drying in block mode must not be stopped by a humidity re-check."""
# 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())
# Must NOT stop the running dry — block mode gates new starts, not active cycles
for call in mock_pm.send_drying_command.call_args_list:
assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
"Block mode must not stop an already-running drying cycle (#1892)"
)
@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}
class TestMidPrintDrying(_DryingTestBase):
"""Tests for the print_drying_enabled path — drying that runs CONCURRENTLY
with an active print on capable hardware (H2D / H2C / H2S / P2S / X2D / X1C /
A2L / H2D Pro on recent firmware). Distinct from idle drying.
Verifies:
- With the toggle ON and capable hardware, a printer in the busy set is
still evaluated and drying fires at the capped temperature.
- The temperature cap is max(40, preset_temp - 5) — protects spools.
- With the toggle OFF, the existing busy-printer skip still applies.
- With the toggle ON but unsupported firmware, the busy-printer skip
still applies (gated by supports_drying_while_printing).
"""
@pytest.fixture
def scheduler(self):
return PrintScheduler()
@staticmethod
def _ams_unit(humidity: str = "75"):
return {
"id": 0,
"module_type": "n3f",
"dry_time": 0,
"humidity_raw": humidity,
"dry_sf_reason": [],
"tray": [{"tray_type": "PLA"}],
}
def _state(self, firmware: str):
state = MagicMock()
state.raw_data = {"ams": [self._ams_unit()]}
state.firmware_version = firmware
return state
@pytest.mark.asyncio
@patch("backend.app.services.print_scheduler.printer_manager")
async def test_running_printer_dries_when_enabled_and_capable(self, mock_pm, scheduler):
"""Toggle ON + capable hardware: running printer dries at capped temp."""
mock_pm.get_status.return_value = self._state("01.03.00.00")
mock_pm.is_connected.return_value = True
mock_pm.get_model.return_value = "H2D"
mock_pm.send_drying_command.return_value = True
scheduler._is_printer_idle = MagicMock(return_value=False)
db = AsyncMock()
settings_returns = {
"queue_drying_enabled": self._make_setting("true"),
"ambient_drying_enabled": self._make_setting("false"),
"print_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))
# Printer 1 is in busy_printers — would normally be skipped
await scheduler._check_auto_drying(db, [], {1})
# PLA preset is 45 degC for n3f; mid-print cap is max(40, 45-5) = 40
mock_pm.send_drying_command.assert_called_once_with(1, 0, 40, 12, mode=1, filament="PLA")
assert 1 in scheduler._drying_in_progress
@pytest.mark.asyncio
@patch("backend.app.services.print_scheduler.printer_manager")
async def test_temp_cap_applied_above_floor(self, mock_pm, scheduler):
"""Higher-temp filament (PETG n3f=65) caps to 60, not floor."""
state = MagicMock()
state.raw_data = {
"ams": [
{
"id": 0,
"module_type": "n3f",
"dry_time": 0,
"humidity_raw": "75",
"dry_sf_reason": [],
"tray": [{"tray_type": "PETG"}],
}
]
}
state.firmware_version = "01.03.00.00"
mock_pm.get_status.return_value = state
mock_pm.is_connected.return_value = True
mock_pm.get_model.return_value = "H2D"
mock_pm.send_drying_command.return_value = True
scheduler._is_printer_idle = MagicMock(return_value=False)
db = AsyncMock()
settings_returns = {
"queue_drying_enabled": self._make_setting("true"),
"ambient_drying_enabled": self._make_setting("false"),
"print_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, [], {1})
# PETG preset 65 -> max(40, 65-5) = 60
mock_pm.send_drying_command.assert_called_once_with(1, 0, 60, 12, mode=1, filament="PETG")
@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_running_printer_skipped_when_toggle_off(self, mock_sd, mock_pm, scheduler):
"""Toggle OFF: running printer is skipped even on capable hardware."""
mock_pm.get_status.return_value = self._state("01.03.00.00")
mock_pm.is_connected.return_value = True
mock_pm.get_model.return_value = "H2D"
scheduler._is_printer_idle = MagicMock(return_value=False)
db = AsyncMock()
settings_returns = {
"queue_drying_enabled": self._make_setting("true"),
"ambient_drying_enabled": self._make_setting("false"),
"print_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))
await scheduler._check_auto_drying(db, [], {1})
mock_pm.send_drying_command.assert_not_called()
@pytest.mark.asyncio
@patch("backend.app.services.print_scheduler.printer_manager")
async def test_running_printer_skipped_when_firmware_too_old(self, mock_pm, scheduler):
"""Toggle ON but firmware below matrix threshold: skip."""
# H2D matrix minimum is 01.03.00.00; this is below
mock_pm.get_status.return_value = self._state("01.02.30.00")
mock_pm.is_connected.return_value = True
mock_pm.get_model.return_value = "H2D"
scheduler._is_printer_idle = MagicMock(return_value=False)
db = AsyncMock()
settings_returns = {
"queue_drying_enabled": self._make_setting("true"),
"ambient_drying_enabled": self._make_setting("false"),
"print_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, [], {1})
mock_pm.send_drying_command.assert_not_called()
@pytest.mark.asyncio
@patch("backend.app.services.print_scheduler.printer_manager")
async def test_running_printer_skipped_when_model_excluded(self, mock_pm, scheduler):
"""Toggle ON, recent firmware, but excluded model (A1): skip."""
mock_pm.get_status.return_value = self._state("99.99.99.99")
mock_pm.is_connected.return_value = True
mock_pm.get_model.return_value = "A1"
scheduler._is_printer_idle = MagicMock(return_value=False)
db = AsyncMock()
settings_returns = {
"queue_drying_enabled": self._make_setting("true"),
"ambient_drying_enabled": self._make_setting("false"),
"print_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, [], {1})
mock_pm.send_drying_command.assert_not_called()