bambuddy/backend/tests/unit/services/test_slice_dispatch_progress.py
maziggy d5153f1de3 feat(slicer): live progress + filament discovery polish + OrcaSlicer warning
End-to-end live progress, two correctness fixes, and a UX warning around
  the upstream OrcaSlicer bugs we discovered while testing.

  LIVE PROGRESS
  =============

  Wire OrcaSlicer / BambuStudio's --pipe progress channel through the
  sidecar -> Bambuddy -> persistent toast so a user-initiated slice shows
  "{name} -- Generating G-code (75%) -- 47s" instead of just elapsed time.
  The same wiring covers the SliceModal's filament-analysis preview slice
  (the real slice that fires before profile picking, used to discover
  which AMS slots an unsliced plate consumes) and the embedded-settings
  fallback path triggered by Orca's --load-settings segfault on complex
  H2D models.

  - Sidecar (orca-slicer-api/bambuddy/profile-resolver, separate commit):
    switch /slice from execFile to spawn, mkfifo per request, parse the
    CLI's structured JSON progress events into a per-process
    ProgressStore, expose GET /slice/progress/:requestId.
  - Bambuddy backend: slicer_api.slice_with_profiles + slice_without_profiles
    accept request_id + on_progress, spawn a 1Hz parallel poller that
    forwards each snapshot via SliceDispatchService.set_progress(job_id,
    ...) onto the matching SliceJob; GET /slice-jobs/:id includes the
    latest snapshot on every poll. The 404 from the early-race window
    (POST fired before sidecar's progressStore.start) is treated as a
    retry rather than terminal -- otherwise the poller bailed before any
    progress could ever arrive.
  - /api/v1/slicer/preview-progress/:requestId proxies the sidecar's
    progress endpoint for the modal's filament-discovery flow (the
    /filament-requirements call is server-originated; the browser can't
    reach the sidecar directly).
  - Frontend: SliceJobTrackerContext re-renders the persistent toast with
    the new format when a useful progress frame is present, falls back
    to elapsed-time-only when the sidecar hasn't emitted yet or doesn't
    support progress. SliceModal.FilamentAnalysisSpinner generates a
    per-(source, plate) UUID, polls the proxy at 1Hz, and mirrors the
    inline spinner contents into a separate persistent toast so the
    preview slice doesn't feel silent either.

  CORRECTNESS FIXES
  =================

  - MakerWorld imports were persisting URL-encoded filenames verbatim
    ("stormtrooper-helmet%20h2d.3mf"). Backend now urllib.parse.unquote
    s the manifest-supplied name and the URL path-tail fallback before
    passing to save_3mf_bytes_to_library; frontend defensively
    decodeURIComponent s in the slice toast / analysis spinner so
    already-imported rows display cleanly without a backfill migration.
  - The fallback path's slice_without_profiles call now forwards the
    same request_id + on_progress as the primary slice_with_profiles
    call so the toast keeps updating across the segfault -> embedded-
    settings retry boundary instead of going blank.

  ORCASLICER WARNING
  ==================

  Verified two upstream OrcaSlicer CLI bugs reproduce on the latest
  nightly (2.4.0-dev, 2026-04-28) with the help of an isolated AppImage
  extract and a minimal sentinel-value-injected cube fixture:
    - OrcaSlicer/OrcaSlicer#12426 -- SIGSEGV in
      update_values_to_printer_extruders_for_multiple_filaments on
      painted multi-extruder 3MFs (commented on the existing thread,
      not a new issue)
    - OrcaSlicer/OrcaSlicer#13386 -- CLI strict-validates parameter
      values BambuStudio writes by default (solid_infill_filament: 0,
      tree_support_wall_count: -1, prime_tower_brim_width: -1) and
      rejects with exit 238, even though Orca's own GUI tolerates
      them (filed by us alongside this change)

  Settings -> Workflow -> Slicer card renders an amber inline warning
  under the preferred-slicer dropdown when orcaslicer is selected,
  linking both upstream issues and recommending BambuStudio until the
  fixes land. Option stays pickable -- users who only slice STLs aren't
  affected by either bug.
2026-04-28 16:36:49 +02:00

109 lines
3.3 KiB
Python

"""Tests for SliceDispatchService.set_progress.
The dispatcher exposes set_progress so the slice-route's parallel poller
(spawned alongside the blocking sidecar slice request) can publish
``{stage, total_percent, plate_index, plate_count}`` snapshots that the
status-poll endpoint surfaces to the UI's persistent progress toast.
"""
from __future__ import annotations
import asyncio
import pytest
from backend.app.services.slice_dispatch import SliceDispatchService
@pytest.mark.asyncio
async def test_set_progress_attaches_snapshot_to_running_job():
dispatcher = SliceDispatchService()
started = asyncio.Event()
release = asyncio.Event()
async def runner(job_id: int) -> dict:
started.set()
# Hold the job in the running state until the test releases it.
await release.wait()
return {"library_file_id": 1}
job = await dispatcher.enqueue(
kind="library_file",
source_id=1,
source_name="x.stl",
run=runner,
)
await started.wait()
# Without progress published yet, the job's progress is None.
assert dispatcher.get(job.id) is not None
assert dispatcher.get(job.id).progress is None
# First snapshot lands on the job.
dispatcher.set_progress(
job.id,
{"stage": "Detecting perimeters", "total_percent": 12},
)
snap = dispatcher.get(job.id).progress
assert snap == {"stage": "Detecting perimeters", "total_percent": 12}
# Second snapshot replaces, doesn't merge — the dispatcher just
# holds the latest frame; the sidecar's pipe protocol always emits
# the full set, so partial-frame merging would be wrong.
dispatcher.set_progress(
job.id,
{"stage": "Generating G-code", "total_percent": 75, "plate_index": 1},
)
snap = dispatcher.get(job.id).progress
assert snap == {
"stage": "Generating G-code",
"total_percent": 75,
"plate_index": 1,
}
# Release the runner so the job completes and the test cleans up.
release.set()
# Yield to the event loop so the runner's completion settles.
await asyncio.sleep(0)
await asyncio.sleep(0)
@pytest.mark.asyncio
async def test_set_progress_silently_ignores_unknown_job_id():
"""A late poll after retention sweep mustn't crash the polling task."""
dispatcher = SliceDispatchService()
# Should be a no-op, not an exception.
dispatcher.set_progress(99999, {"stage": "x", "total_percent": 50})
@pytest.mark.asyncio
async def test_set_progress_can_clear_to_none():
"""Allow clearing — useful when the slice transitions to a final
state and we want the toast to revert to the elapsed-time fallback
on subsequent polls."""
dispatcher = SliceDispatchService()
started = asyncio.Event()
release = asyncio.Event()
async def runner(job_id: int) -> dict:
started.set()
await release.wait()
return {"library_file_id": 1}
job = await dispatcher.enqueue(
kind="library_file",
source_id=1,
source_name="x.stl",
run=runner,
)
await started.wait()
dispatcher.set_progress(job.id, {"stage": "x", "total_percent": 50})
assert dispatcher.get(job.id).progress is not None
dispatcher.set_progress(job.id, None)
assert dispatcher.get(job.id).progress is None
release.set()
await asyncio.sleep(0)
await asyncio.sleep(0)