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