bambuddy/backend/tests/unit/test_upload_progress_bridge.py
maziggy 9033b0f81e feat(toast): restore upload-progress toast for scheduler dispatches (#1625 follow-up)
FTP push to the printer into the server-side scheduler tick. That
removed the browser-side upload the old XHR-progress modal listened
to — users only saw the queue item flip to "active" with no visibility
into the FTP push + the H2D/H2D Pro 80-210 s project_file digestion
window before the printer actually started.

Port the legacy bg-dispatch toast rendering from
0b43ac0d:frontend/src/contexts/ToastContext.tsx lines 510-650 back in
place verbatim — same DOM tree, same Tailwind classes, same
formatFileSize bytes line, same uppercase status chip, same collapse
chevron, same awaitingPrinter derivation, same auto-dismiss. The only
adapt is the event ingestion: a useEffect maps the four scheduler-side
WS events to the legacy DispatchToastJob shape.

The toast materializes when the FTP push to the printer ACTUALLY
STARTS (queue_item_uploading) — NOT on POST /queue. A draft that
fired at queue-add made the toast jump to "Dispatched" before any
upload had happened.

Four backend WS events drive it: uploading (carries printer_name +
total_bytes), upload_progress (throttled at 200 ms / 256 KB to match
legacy background_dispatch.py:614-615 1:1, first call always emits,
completion always emits; an _UploadProgressBridge bridges from the
FTP executor thread to the asyncio loop), acked (printer transitioned
out of pre_state), failed (with a reason key the toast looks up as
dispatchToast.failed.{reason}). No queue_item_dispatched event: the
legacy path kept status=processing from upload start until printer
ack, "Awaiting printer..." derives from upload_progress_pct >= 99.9
(legacy uploadDoneAwaitingPrinter trick).

Per-user routing: WS connect resolves the principal username to
User.id once and stashes it on websocket.state, so
ws_manager.broadcast_to_user filters O(connections). Auth-disabled
installs route user_id=None to all connections — matches the legacy
single-user behaviour. The watchdog receives created_by_id through a
new kwarg so the static method can still emit acked without
re-fetching the queue item.
2026-06-27 11:28:00 +02:00

113 lines
3.6 KiB
Python

"""Throttle contract for the scheduler's upload-progress bridge.
The legacy bg-dispatch path (last seen in
backend/app/services/background_dispatch.py before commit 61c8898b) used:
- 200 ms time gate
- 256 KB byte gate
- always emit on first call and at uploaded >= total
The scheduler-driven dispatch must feel identical to the pre-#1625 path,
so the throttle here mirrors that 1:1.
"""
from __future__ import annotations
import asyncio
import pytest
from backend.app.services.print_scheduler import _UploadProgressBridge
@pytest.mark.asyncio
async def test_first_call_always_emits(monkeypatch):
bridge = _UploadProgressBridge(user_id=1, queue_item_id=1)
bridge._loop = asyncio.get_running_loop()
calls: list[tuple[int, int]] = []
def fake_run(coro, loop): # noqa: ARG001
coro.close()
calls.append((1, 1))
monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
# First chunk, tiny payload — must emit so the user sees something
# even for sub-chunk-size files where the upload finishes inside the
# very first FTP callback.
bridge(8192, 16384)
assert len(calls) == 1
@pytest.mark.asyncio
async def test_emit_at_completion_even_under_throttle_gates(monkeypatch):
bridge = _UploadProgressBridge(user_id=1, queue_item_id=2)
bridge._loop = asyncio.get_running_loop()
calls: list[int] = []
def fake_run(coro, loop): # noqa: ARG001
coro.close()
calls.append(1)
monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
# First call, force pretend-recent emit so neither time nor byte gate fires.
bridge(50_000, 1_000_000)
bridge._last_emit_monotonic = float("inf") * 0 + 1e18 # implausibly recent
bridge._last_emit_bytes = 50_000
# Mid-upload chunk well under both gates — would normally skip.
bridge(60_000, 1_000_000)
# Completion — must always emit so the bar locks at 100%.
bridge(1_000_000, 1_000_000)
# First + completion. Mid-upload chunk skipped (last_emit_monotonic is
# in the future, byte step is only 10 KB).
assert len(calls) == 2
@pytest.mark.asyncio
async def test_emit_after_256kb_step_even_under_time_gate(monkeypatch):
bridge = _UploadProgressBridge(user_id=1, queue_item_id=3)
bridge._loop = asyncio.get_running_loop()
calls: list[int] = []
def fake_run(coro, loop): # noqa: ARG001
coro.close()
calls.append(1)
monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
bridge(8192, 10_000_000) # first emit
# Pretend time gate not met but byte gate IS met (256 KB further).
bridge._last_emit_monotonic = 1e18
bridge._last_emit_bytes = 8192
bridge(8192 + 256 * 1024 + 1, 10_000_000)
assert len(calls) == 2
@pytest.mark.asyncio
async def test_no_emit_when_total_bytes_zero(monkeypatch):
bridge = _UploadProgressBridge(user_id=1, queue_item_id=4)
bridge._loop = asyncio.get_running_loop()
calls: list[int] = []
def fake_run(coro, loop): # noqa: ARG001
coro.close()
calls.append(1)
monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
bridge(0, 0)
bridge(100, 0)
assert calls == []
def test_silent_when_no_running_loop_captured():
"""Constructed outside an asyncio loop — the bridge captures None and
every call is a no-op."""
bridge = _UploadProgressBridge(user_id=1, queue_item_id=5)
assert bridge._loop is None
bridge(1, 100) # must not raise