mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
* 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>
200 lines
7.7 KiB
Python
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
|