Files
bambuddy/backend/tests/unit/test_scheduled_drying_routes.py
T
d37ce94f81 Feature: Scheduled drying (#2703)
* feat: add ScheduledDrying model for delayed drying runs (#2638)

* Release the printer when a scheduled dry ends (#2638)

_check_scheduled_dryings marks a printer as drying in _drying_in_progress,
which is shared with auto-drying. Auto-drying prunes that map in
_sync_drying_state(), but that call sits behind its enabled check, and this is
the first writer that runs whether auto-drying is on or not. With it off --
the default -- nothing dropped the entry short of a print being dispatched to
the same printer, so the next scheduled run parked on "already_drying"
forever and queue_drying_block held that printer's prints too. A nightly
off-peak dry with no printing in between is exactly the workflow this feature
is for: night one worked, every night after it silently did not. The check now
releases what it acquired, covering both a run that ends mid-pass and one
cancelled through the route between passes.

The retention prune ran on every pass. Issuing the DELETE is what opens a
write transaction, this method is called every 3s while the queue dispatches,
and rows only become prunable a week after they finish, so it is now gated to
hourly on a monotonic stamp -- with the first pass after a restart still
reaping whatever the dead process left behind.

Both drying paths now pick the blocking dry_sf_reason through one rule.
The immediate endpoint quoted whichever code the firmware listed first while
the scheduler prioritised power over retract, so one blocked AMS read two ways
depending on which button you pressed. drying_preflight.primary_reason_code
holds the order and both call it, including the flame button's tooltip, which
had no wording for filament at the outlet at all and sent those users to the
generic "can't start drying right now".

scheduled_drying joins the model list in init_db. The table was already
created -- importing the package registers it -- but it was the only model
relying on that indirection.

Tests: the release (completion and route-cancel), the prune throttle, the
shared reason rule, the tooltip priority, and four driving the real check_queue,
which nothing covered before -- a pass with no rows still dispatching prints, a
due row dispatching, and a failed row not stalling the queue behind it. Each
one fails against the code it guards.

---------

Co-authored-by: MartinNYHC <martin@bambuddy.cool>
Co-authored-by: maziggy <mz@v8w.de>
2026-08-15 13:40:21 +02:00

200 lines
7.7 KiB
Python

"""Route tests for /scheduled-dryings (#2638)."""
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
def _future_iso(hours: int = 2) -> str:
return (datetime.now(timezone.utc) + timedelta(hours=hours)).isoformat()
@pytest.mark.asyncio
async def test_create_and_list(async_client, printer_factory):
printer = await printer_factory()
resp = await async_client.post(
"/api/v1/scheduled-dryings",
json={"printer_id": printer.id, "ams_id": 0, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "pending"
assert body["printer_id"] == printer.id
listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
assert listed.status_code == 200
assert [row["id"] for row in listed.json()] == [body["id"]]
@pytest.mark.asyncio
async def test_create_rejects_past_start_after(async_client, printer_factory):
printer = await printer_factory()
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
resp = await async_client.post(
"/api/v1/scheduled-dryings",
json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": past},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_create_unknown_printer_404(async_client):
resp = await async_client.post(
"/api/v1/scheduled-dryings", json={"printer_id": 99999, "temp": 65, "duration_hours": 8}
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_create_invalid_temp_422(async_client, printer_factory):
printer = await printer_factory()
resp = await async_client.post(
"/api/v1/scheduled-dryings", json={"printer_id": printer.id, "temp": 90, "duration_hours": 8}
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_cancel_pending(async_client, printer_factory):
printer = await printer_factory()
created = await async_client.post(
"/api/v1/scheduled-dryings",
json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
)
row_id = created.json()["id"]
resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "cancelled"
# Cancelled rows disappear from the active list
listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
assert listed.json() == []
# Second cancel is a 400 (not active any more)
resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row_id}")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_cancel_running_sends_stop_command(async_client, printer_factory, db_session):
from backend.app.models.scheduled_drying import ScheduledDrying
printer = await printer_factory()
row = ScheduledDrying(printer_id=printer.id, ams_id=1, temp=65, duration_hours=8, status="running")
db_session.add(row)
await db_session.commit()
await db_session.refresh(row)
with patch("backend.app.api.routes.scheduled_dryings.printer_manager") as mock_pm:
mock_pm.send_drying_command.return_value = True
resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row.id}")
assert resp.status_code == 200
mock_pm.send_drying_command.assert_called_once_with(printer.id, 1, 0, 0, mode=0)
@pytest.mark.asyncio
async def test_screen_only_model_rejected_at_schedule_time(async_client, printer_factory):
"""A P1S can never run a scheduled dry, so say so in the UI now."""
printer = await printer_factory(model="P1S")
resp = await async_client.post(
"/api/v1/scheduled-dryings",
json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
)
assert resp.status_code == 400
assert "screen" in resp.json()["detail"].lower()
@pytest.mark.asyncio
async def test_offline_printer_is_still_schedulable(async_client, printer_factory):
"""Firmware is unreadable while offline and may be upgraded before the run."""
printer = await printer_factory()
resp = await async_client.post(
"/api/v1/scheduled-dryings",
json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
)
assert resp.status_code == 200, resp.text
@pytest.mark.asyncio
async def test_stale_firmware_rejected_when_printer_is_online(async_client, printer_factory):
printer = await printer_factory()
state = type("S", (), {"firmware_version": "01.05.00.00", "raw_data": {}})()
with patch("backend.app.api.routes.scheduled_dryings.printer_manager") as mock_pm:
mock_pm.get_status.return_value = state
resp = await async_client.post(
"/api/v1/scheduled-dryings",
json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_timestamps_carry_z_suffix(async_client, printer_factory):
"""Matches every print_queue route, which the frontend parses the same way."""
printer = await printer_factory()
resp = await async_client.post(
"/api/v1/scheduled-dryings",
json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
)
body = resp.json()
assert body["start_after"].endswith("Z")
assert body["created_at"].endswith("Z")
@pytest.mark.asyncio
async def test_failed_rows_are_listed_and_dismissable(async_client, printer_factory, db_session):
"""A run that only fails at dispatch has to reach the client, not just the log."""
from backend.app.models.scheduled_drying import ScheduledDrying
printer = await printer_factory()
row = ScheduledDrying(
printer_id=printer.id,
temp=65,
duration_hours=8,
status="failed",
error_message="Drying not supported for this printer model or firmware version",
)
db_session.add(row)
await db_session.commit()
await db_session.refresh(row)
listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
assert [r["id"] for r in listed.json()] == [row.id]
assert listed.json()[0]["error_message"].startswith("Drying not supported")
resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row.id}")
assert resp.status_code == 200
assert resp.json()["status"] == "dismissed"
assert (await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")).json() == []
# Dismissing twice is a 404, not a second dismiss: the row is gone.
assert (await async_client.delete(f"/api/v1/scheduled-dryings/{row.id}")).status_code == 404
@pytest.mark.asyncio
async def test_list_orders_by_start_after_then_id(async_client, printer_factory):
printer = await printer_factory()
later = await async_client.post(
"/api/v1/scheduled-dryings",
json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso(5)},
)
sooner = await async_client.post(
"/api/v1/scheduled-dryings",
json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso(2)},
)
listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
assert [r["id"] for r in listed.json()] == [sooner.json()["id"], later.json()["id"]]
@pytest.mark.asyncio
async def test_over_long_filament_422(async_client, printer_factory):
printer = await printer_factory()
resp = await async_client.post(
"/api/v1/scheduled-dryings",
json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "filament": "X" * 51},
)
assert resp.status_code == 422