Files
bambuddy/backend/tests/unit/services/test_background_dispatch.py
T
maziggy 173edd9b7c ● fix(vp-queue): inherit slicer print options instead of always using defaults (#1403)
Reporter sliced in OrcaSlicer with timelapse on, sent the job to a VP
  queue, started from the queue, and got no timelapse video. Their
  dispatch chain itself was correct (queue item -> scheduler -> MQTT
  command honors `timelapse`); the gap was at queue-add time.

  The VP's `_add_to_print_queue` reads `default_timelapse` (and the four
  other print-option settings) from the workflow settings card. That was
  introduced in #1235 to stop column-level defaults from winning. But it
  also discarded the slicer's actual choice carried on the MQTT
  `project_file` command, which all the slicers (Studio / Handy / Orca)
  ship as `timelapse: true|1`. Result: a user with the new-install value
  `default_timelapse=false` had to either flip the global setting or
  edit every queue item by hand, even though their slicer's "Print
  options" UI clearly said "record timelapse".

  Investigation went wider than #1403 because Martin's hypothesis was
  "the print options modal isn't respected either." Cross-checking
  86 captured P1S `project_file` commands across the support packages
  shows 46 from the queue scheduler and 33 from background_dispatch
  emitting `"timelapse": true` correctly to real printers - the modal +
  re-print path is intact end-to-end. The slicer-side gap was the only
  real bug. Two unrelated dead-code issues turned up in the same dig and
  are folded in below.

  Fix (VP queue inheritance)

  - `on_print_command` in the VP manager now stashes the slicer's
    project_file dict keyed by filename, then signals an asyncio.Event.
  - `_add_to_print_queue` checks the dict first; if empty, creates the
    event and waits up to 2 s for it before reading the settings
    fallback. Each option flows through per-field - slicer value wins
    if present, else the existing settings default (so users who
    explicitly set `default_timelapse=true` in their VP workflow card
    still get that on slicers that don't send a print command).
  - MQTT field naming preserved exactly: `bed_leveling` (single L) on
    the wire stays mapped to `bed_levelling` (double L) on the Bambuddy
    column. Integer 0/1 from H-family slicers and bool true/false from
    P1/X1 slicers both coerce via `bool()`.
  - Capture is gated on `mode == "print_queue"` so immediate / review /
    proxy modes keep their pre-fix no-op `on_print_command` and don't
    accumulate stashed entries over the VP's uptime.
  - Wait is also skipped when there's no MQTT server attached
    (`self._mqtt is None`), so unit tests that invoke
    `_add_to_print_queue` directly don't pay the 2 s tax.
  - Capture is consumed on use so the dict stays bounded.
  - `printer_manager.get_status(...).get(...)` against a `PrinterState`
    dataclass that has no `.get()` method.
  - Every print option discarded (timelapse, bed_levelling, AMS mapping).

  The route 500'd before ever reaching the printer. Rewritten to mirror
  `POST /print-queue/{item_id}/start`: clear `manual_start=False` on the
  next pending queue item and let the scheduler dispatch with the
  queue's stored options intact. Response shape preserved.

  Side-bug b: vibration_cali default drift in background_dispatch

  - `ReprintRequest.vibration_cali` and `FilePrintRequest.vibration_cali`
    both default to `True` (matches Bambu Studio behavior for X1/P1).
  - Both `_process_job` call sites read
    `job.options.get("vibration_cali", False)`.

  Cosmetic today because the frontend always sends the field, but a
  latent landmine for any future caller that bypasses the schema. Both
  sites flipped to `True`.
2026-05-18 09:38:53 +02:00

416 lines
15 KiB
Python

"""Unit tests for background dispatch service."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.services.background_dispatch import (
ActiveDispatchState,
BackgroundDispatchService,
DispatchEnqueueRejected,
PrintDispatchJob,
)
@pytest.mark.asyncio
async def test_dispatch_rejects_when_printer_busy_printing():
"""Reject enqueue when target printer is already printing."""
service = BackgroundDispatchService()
with (
patch(
"backend.app.services.background_dispatch.printer_manager.get_status",
return_value=SimpleNamespace(state="RUNNING", gcode_file="active.gcode.3mf"),
),
pytest.raises(DispatchEnqueueRejected, match="currently busy printing"),
):
await service.dispatch_reprint_archive(
archive_id=1,
archive_name="Test Archive",
printer_id=10,
printer_name="Printer A",
options={},
requested_by_user_id=None,
requested_by_username=None,
)
@pytest.mark.asyncio
async def test_dispatch_enqueues_job_and_broadcasts_state():
"""Enqueue succeeds and emits websocket queue update."""
service = BackgroundDispatchService()
with (
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
patch(
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
) as mock_broadcast,
):
result = await service.dispatch_print_library_file(
file_id=22,
filename="cube.gcode.3mf",
printer_id=7,
printer_name="Printer B",
options={"plate_id": 2},
requested_by_user_id=5,
requested_by_username="tester",
)
assert result["status"] == "dispatched"
assert result["dispatch_job_id"] == 1
assert result["dispatch_position"] == 1
assert len(service._queued_jobs) == 1
mock_broadcast.assert_awaited_once()
payload = mock_broadcast.await_args.args[0]
assert payload["type"] == "background_dispatch"
assert payload["data"]["recent_event"]["status"] == "dispatched"
@pytest.mark.asyncio
async def test_dispatch_library_file_defaults_cleanup_flag_false():
"""cleanup_library_after_dispatch defaults to False when not passed — protects
File Manager / Project Detail / queued-library-file paths from surprise deletion."""
service = BackgroundDispatchService()
with (
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock),
):
await service.dispatch_print_library_file(
file_id=1,
filename="cube.gcode.3mf",
printer_id=1,
printer_name="Printer A",
options={},
requested_by_user_id=None,
requested_by_username=None,
)
assert len(service._queued_jobs) == 1
assert service._queued_jobs[0].cleanup_library_after_dispatch is False
@pytest.mark.asyncio
async def test_dispatch_library_file_propagates_cleanup_flag_true():
"""cleanup_library_after_dispatch=True arrives on the queued job so the runner
can delete the transient LibraryFile after the print is accepted by the printer."""
service = BackgroundDispatchService()
with (
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock),
):
await service.dispatch_print_library_file(
file_id=1,
filename="cube.gcode.3mf",
printer_id=1,
printer_name="Printer A",
options={},
requested_by_user_id=42,
requested_by_username="alice",
cleanup_library_after_dispatch=True,
)
assert len(service._queued_jobs) == 1
job = service._queued_jobs[0]
assert job.cleanup_library_after_dispatch is True
# Sanity: other fields still wired correctly
assert job.requested_by_user_id == 42
assert job.requested_by_username == "alice"
assert job.kind == "print_library_file"
@pytest.mark.asyncio
async def test_cancel_queued_job_removes_it_and_broadcasts():
"""Cancelling queued job removes it immediately."""
service = BackgroundDispatchService()
with (
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
patch(
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
) as mock_broadcast,
):
result = await service.dispatch_reprint_archive(
archive_id=1,
archive_name="benchy.gcode.3mf",
printer_id=1,
printer_name="Printer 1",
options={},
requested_by_user_id=None,
requested_by_username=None,
)
mock_broadcast.reset_mock()
cancel_result = await service.cancel_job(result["dispatch_job_id"])
assert cancel_result["cancelled"] is True
assert cancel_result["pending"] is False
assert len(service._queued_jobs) == 0
assert service._batch_total == 0
mock_broadcast.assert_awaited_once()
payload = mock_broadcast.await_args.args[0]
assert payload["data"]["recent_event"]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_cancel_active_job_marks_pending_and_sets_cancel_flag():
"""Cancelling active job marks it as pending cancellation."""
service = BackgroundDispatchService()
job = PrintDispatchJob(
id=42,
kind="reprint_archive",
source_id=100,
source_name="gearbox.gcode.3mf",
printer_id=3,
printer_name="Printer C",
)
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Uploading...")
with patch(
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
) as mock_broadcast:
result = await service.cancel_job(job.id)
assert result["cancelled"] is True
assert result["pending"] is True
assert job.id in service._cancel_requested_job_ids
mock_broadcast.assert_awaited_once()
payload = mock_broadcast.await_args.args[0]
assert payload["data"]["recent_event"]["status"] == "cancelling"
def test_resolve_plate_id_uses_request_value_when_provided(tmp_path):
"""Explicit plate_id wins over auto-detection."""
file_path = tmp_path / "dummy.3mf"
file_path.write_text("not-a-zip")
plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=9)
assert plate_id == 9
def test_resolve_plate_id_auto_detects_from_3mf(tmp_path):
"""Auto-detect plate from Metadata/plate_X.gcode entry."""
import zipfile
file_path = tmp_path / "multi.3mf"
with zipfile.ZipFile(file_path, "w") as zf:
zf.writestr("Metadata/plate_7.gcode", b"G1 X0 Y0")
plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=None)
assert plate_id == 7
def test_is_sliced_file_recognizes_supported_extensions():
"""Only .gcode and .gcode.3mf should be accepted."""
assert BackgroundDispatchService._is_sliced_file("part.gcode") is True
assert BackgroundDispatchService._is_sliced_file("part.gcode.3mf") is True
assert BackgroundDispatchService._is_sliced_file("part.3mf") is False
def test_dispatch_option_defaults_align_with_request_schema_defaults():
"""The `job.options.get("<field>", <default>)` calls in the dispatch
loop must use the same default as the Pydantic request schema. If a
field is missing from options (e.g. an internal caller bypassing the
schema), the resulting print command must match what a fresh
`ReprintRequest()` / `FilePrintRequest()` would produce — anything
else means certain fields silently flip depending on which entry
point queued the job.
Earlier `vibration_cali` had a False default in the dispatch loop
against a True schema default, latent only because every existing
caller always sent the field.
"""
import inspect
from backend.app.schemas.archive import ReprintRequest
from backend.app.schemas.library import FilePrintRequest
from backend.app.services import background_dispatch as bd
fields = ("bed_levelling", "flow_cali", "vibration_cali", "layer_inspect", "timelapse", "use_ams")
reprint_defaults = {f: getattr(ReprintRequest(), f) for f in fields}
libprint_defaults = {f: getattr(FilePrintRequest(), f) for f in fields}
assert reprint_defaults == libprint_defaults, (
"ReprintRequest and FilePrintRequest must share the same defaults for these fields"
)
src = inspect.getsource(bd)
for field, expected_default in reprint_defaults.items():
literal = "True" if expected_default else "False"
needle = f'{field}=job.options.get("{field}", {literal})'
count = src.count(needle)
assert count == 2, (
f"Expected exactly 2 occurrences of `{needle}` in background_dispatch (one per "
f"`_process_job` branch). Found {count}. A drift between the request schema's "
f"default for `{field}` and the dispatch loop's `.get()` default means callers "
f"that bypass the schema will get inconsistent behaviour."
)
@pytest.mark.asyncio
async def test_cancel_job_not_found_returns_false():
"""Cancelling a nonexistent job returns not_found."""
service = BackgroundDispatchService()
with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
result = await service.cancel_job(999)
assert result["cancelled"] is False
assert result["reason"] == "not_found"
@pytest.mark.asyncio
async def test_cancel_job_single_lock_covers_both_active_and_queued():
"""cancel_job checks both active and queued jobs under a single lock acquisition.
Regression test for TOCTOU race: previously two separate lock acquisitions allowed
the dispatcher loop to move a job from queue to active between them, causing cancel
to find it in neither place.
"""
service = BackgroundDispatchService()
# Set up a job in the queue AND an active job for a different printer
active_job = PrintDispatchJob(
id=1,
kind="reprint_archive",
source_id=10,
source_name="active.3mf",
printer_id=1,
printer_name="Printer 1",
)
service._active_jobs[active_job.id] = ActiveDispatchState(job=active_job, message="Uploading...")
queued_job = PrintDispatchJob(
id=2,
kind="reprint_archive",
source_id=20,
source_name="queued.3mf",
printer_id=2,
printer_name="Printer 2",
)
service._queued_jobs.append(queued_job)
service._batch_total = 2
with patch(
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
) as mock_broadcast:
# Cancel the queued job — should find it in single lock acquisition
result = await service.cancel_job(2)
assert result["cancelled"] is True
assert result["pending"] is False
assert len(service._queued_jobs) == 0
# Active job should be untouched
assert 1 in service._active_jobs
mock_broadcast.assert_awaited_once()
payload = mock_broadcast.await_args.args[0]
assert payload["data"]["recent_event"]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_mark_job_finished_resets_batch_when_all_done():
"""Batch counters reset after last job completes."""
service = BackgroundDispatchService()
job = PrintDispatchJob(
id=1,
kind="reprint_archive",
source_id=10,
source_name="test.3mf",
printer_id=1,
printer_name="Printer 1",
)
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
service._batch_total = 1
with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
await service._mark_job_finished(job, failed=False, message="Complete")
assert service._batch_total == 0
assert service._batch_completed == 0
assert service._batch_failed == 0
@pytest.mark.asyncio
async def test_mark_job_finished_no_reset_when_jobs_remain():
"""Batch counters NOT reset when queued jobs remain."""
service = BackgroundDispatchService()
job = PrintDispatchJob(
id=1,
kind="reprint_archive",
source_id=10,
source_name="test.3mf",
printer_id=1,
printer_name="Printer 1",
)
remaining_job = PrintDispatchJob(
id=2,
kind="reprint_archive",
source_id=20,
source_name="next.3mf",
printer_id=2,
printer_name="Printer 2",
)
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
service._queued_jobs.append(remaining_job)
service._batch_total = 2
with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
await service._mark_job_finished(job, failed=False, message="Complete")
# Batch counters should NOT be reset — remaining job still queued
assert service._batch_total == 2
assert service._batch_completed == 1
@pytest.mark.asyncio
async def test_mark_job_finished_batch_reset_rechecks_under_lock():
"""Batch reset re-checks condition inside second lock acquisition.
Regression test for TOCTOU: a new dispatch between the two lock acquisitions
could get its counters zeroed if the re-check is missing.
"""
service = BackgroundDispatchService()
job = PrintDispatchJob(
id=1,
kind="reprint_archive",
source_id=10,
source_name="test.3mf",
printer_id=1,
printer_name="Printer 1",
)
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
service._batch_total = 1
original_broadcast = AsyncMock()
async def inject_new_job_during_broadcast(msg):
"""Simulate a new dispatch arriving between the two lock acquisitions."""
await original_broadcast(msg)
# After broadcast (lock released), inject a new job before reset re-check
if not service._queued_jobs:
new_job = PrintDispatchJob(
id=99,
kind="reprint_archive",
source_id=99,
source_name="injected.3mf",
printer_id=5,
printer_name="Printer 5",
)
service._queued_jobs.append(new_job)
service._batch_total = 1
with patch(
"backend.app.services.background_dispatch.ws_manager.broadcast",
side_effect=inject_new_job_during_broadcast,
):
await service._mark_job_finished(job, failed=False, message="Complete")
# Re-check should prevent reset since a new job appeared
assert service._batch_total == 1
assert len(service._queued_jobs) == 1