mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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.
109 lines
3.3 KiB
Python
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)
|