mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
fix(#1128): broadcast printer_status when awaiting_plate_clear flips
awaiting_plate_clear is a Bambuddy-side flag, not a printer-side one,
so toggling it does not produce an MQTT push from the printer. Commit
4e86e8c added the flag to the printer_status payload so MQTT-driven
broadcasts (e.g. when a print finishes and on_print_complete sets the
flag to True alongside a state transition to FINISH) carry it. The
reverse transition didn't: POST /printers/{id}/clear-plate mutated
PrinterManager._awaiting_plate_clear and persisted to the DB, but
emitted no printer_status WebSocket update — and the in-main.py
status-change broadcaster's status_key dedup intentionally excludes
Bambuddy-side flags, so even a coincidentally-arriving MQTT push
wouldn't reflect the change.
The "Mark plate as cleared" button on the printer card disappeared
"immediately" after a click only because the React Query cache was
being optimistically updated client-side; clearing the flag through
any other route (an admin script, a second tab, an automation hitting
the endpoint directly, the scheduler at print_scheduler.py:1844 when
dispatching the next queued print) silently left every UI subscriber
but the originating tab stale until a coincidental status refresh.
Centralised the broadcast in PrinterManager.set_awaiting_plate_clear
itself rather than at each call site, so every current AND future
caller is covered without remembering to wire it up: a new
_broadcast_status_change(printer_id) private coroutine is scheduled
alongside the existing _persist_awaiting_plate_clear whenever the flag
flips under a running event loop. Lazy-imports ws_manager to keep
printer_manager.py clean of application-layer infra at module-import
time, short-circuits when get_status returns None (printer
disconnected — the next reconnect produces a fresh push anyway), and
swallows ws_manager.send_printer_status failures so the persistence
path can complete even if the WS layer is temporarily unavailable.
The same hook is now in place for any other Bambuddy-side flag that
gets added to printer_state_to_dict later — they'll all need to
broadcast their own changes for the same reason.
8 new regression tests in test_printer_manager_status_broadcast.py:
schedules-on-True/False/loop-running/no-loop/loop-stopped contracts,
_broadcast_status_change happy path with payload assertion,
skip-when-no-state, swallow-WS-errors, and an end-to-end live-loop
test that fires set_awaiting_plate_clear(False) and asserts a
broadcast lands with awaiting_plate_clear: false in the payload.
Existing 24 tests in test_scheduler_clear_plate.py continue to pass
unchanged because they instantiate PrinterManager() without
attaching a loop (sync unit-test path) — the new _schedule_async
call short-circuits on the same loop check the existing persistence
call already used.
This commit is contained in:
@@ -31,6 +31,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Per-request trace ID column on every log line, plumbed through HTTP access log + application logs + response headers** — Builds on the new uvicorn-access-log-into-bambuddy.log change below: the access line tells you *who* called an endpoint, but until now there was no way to tie that line to the application records emitted on the server side while handling that request. A new FastAPI middleware (`trace_id_middleware` in `main.py`, sourced from `backend.app.core.trace`) stamps each request with a fresh 8-char hex ID (or honours a sane inbound `X-Trace-Id` header for cross-system correlation), stores it in a `ContextVar` so any code in the request's call stack can read it, echoes it on the response as `X-Trace-Id`, and a new `TraceIDFilter` injects it into every `LogRecord` so the format string `[%(trace_id)s]` resolves to the right ID for the right request. ContextVars (rather than `request.state`) are the right plumbing here because asyncio copies the current context into every `asyncio.create_task`, so background work spawned from inside a request inherits the trace ID without explicit threading; the logging filter has no access to the FastAPI request object regardless. Records emitted outside any request scope (startup, MQTT callbacks, scheduler) get a stable `-` placeholder so the column stays visually aligned and missing values are obvious in `grep`. Inbound `X-Trace-Id` is hard-validated against a strict whitelist (`[A-Za-z0-9_-]+`, max 64 chars) before being honoured — a hostile or buggy caller cannot smuggle log-injection payloads (newlines, control chars, megabyte blobs) into `bambuddy.log` via the trace-ID column; values that fail the gate silently trigger a freshly minted server-side ID rather than failing the request. Middleware is decorated AFTER `auth_middleware` on purpose: Starlette stacks `@app.middleware` decorators LIFO so the last-decorated runs first inbound, making trace stamp the OUTERMOST layer — auth log lines and every record emitted on the way down to and back from the route handler all carry the same ID. Output now looks like `2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200` paired with the route handler's `2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent stop print command` — one `grep a4f3b1e7` away from the full causality chain. 30 new tests across `tests/unit/test_trace.py` (placeholder when no request scope, filter copies ContextVar value onto records, ID propagates into spawned tasks via asyncio context copy, concurrent requests don't leak IDs into each other, generator produces unique hex IDs, hostile payloads rejected by validator, max-length boundary, dash/underscore variants accepted) plus `tests/integration/test_trace_middleware.py` (X-Trace-Id header echoed on response, body and header IDs match, each request gets a unique ID, generator format stays short hex, safe inbound IDs honoured, hostile inbound IDs replaced, overlong inbound IDs replaced, ContextVar reset cleanly after request).
|
||||
|
||||
### Fixed
|
||||
- **Plate-clear button stayed visible after the API cleared `awaiting_plate_clear` outside the printer-card click path** ([#1128](https://github.com/maziggy/bambuddy/issues/1128)) — `awaiting_plate_clear` is a Bambuddy-side flag, not a printer-side one, so toggling it does not produce an MQTT push from the printer. Commit 4e86e8c added the flag to the `printer_status` payload so MQTT-driven broadcasts (e.g. when a print finishes and on_print_complete sets the flag to True alongside a state transition to FINISH) carry it correctly. The reverse transition didn't get the same treatment: `POST /printers/{id}/clear-plate` mutated `PrinterManager._awaiting_plate_clear` and persisted to the DB, but emitted no `printer_status` WebSocket update — and the in-`main.py` status-change broadcaster's `status_key` deduplication intentionally excludes Bambuddy-side flags, so even a coincidentally-arriving MQTT push wouldn't reflect the change. The "Mark plate as cleared" button on the printer card disappeared "immediately" after a click only because the React Query cache was being optimistically updated client-side; clearing the flag through any other route (an admin script, a second tab, an automation hitting the endpoint directly, the scheduler at `print_scheduler.py:1844` when dispatching the next queued print) silently left every UI subscriber but the originating tab stale until a coincidental status refresh. Centralised the broadcast in `PrinterManager.set_awaiting_plate_clear` itself rather than at each call site, so every current AND future caller is covered without remembering to wire it up: a new `_broadcast_status_change(printer_id)` private coroutine is scheduled alongside the existing `_persist_awaiting_plate_clear` whenever the flag flips under a running event loop. The broadcast lazy-imports `ws_manager` to keep `printer_manager.py` clean of application-layer infra at module-import time, short-circuits when `get_status` returns `None` (printer disconnected — the next reconnect produces a fresh push anyway), and swallows `ws_manager.send_printer_status` failures so the persistence path can complete even if the WS layer is temporarily unavailable. The same hook is now in place for any other Bambuddy-side flag that gets added to `printer_state_to_dict` later — they'll all need to broadcast their own changes for the same reason. 8 new regression tests in `test_printer_manager_status_broadcast.py`: schedules-on-True/False/loop-running/no-loop/loop-stopped contracts, `_broadcast_status_change` happy path with payload assertion, skip-when-no-state, swallow-WS-errors, and an end-to-end live-loop test that fires `set_awaiting_plate_clear(False)` and asserts a broadcast lands with `awaiting_plate_clear: false` in the payload. Existing 24 tests in `test_scheduler_clear_plate.py` continue to pass unchanged because they instantiate `PrinterManager()` without attaching a loop (sync unit-test path) — the new `_schedule_async` call short-circuits on the same loop check the existing persistence call already used. Thanks to @EdwardChamberlain for the precise root-cause analysis (down to the exact line and the suggested `ws_manager.send_printer_status()` fix).
|
||||
|
||||
- **Uvicorn HTTP access log was missing from `bambuddy.log`, leaving rogue server-state changes untraceable** — When an HTTP endpoint that mutates server state fires unexpectedly (the canonical example: a print spontaneously stopping mid-job because something hit `POST /printers/{id}/print/stop`), the only on-disk trail was Bambuddy's own application log — which by design only records the *outbound* MQTT publish (`Sent stop print command`), not the *inbound* HTTP call that triggered it. The result was an unsolvable mystery on 2026-04-26: prints stopping with no preceding Bambuddy-side log line, no way to identify the caller, and the rotated container stdout already gone by the time the support pack was generated. Root cause: uvicorn ships its `access` logger with `propagate=False` by default, so the existing `RotatingFileHandler` attached to root never received those records. `main.py` now attaches the same file handler directly to `logging.getLogger("uvicorn.access")` and applies a new `WriteRequestsOnlyFilter` (`backend/app/core/logging_filters.py`) that keeps `POST` / `PUT` / `PATCH` / `DELETE` and drops `GET` / `HEAD` / `OPTIONS`. Status polls, camera streams, snapshot fetches, websocket upgrades, and CORS preflights account for the bulk of access traffic on a running install and none of them can change server state on their own — dropping them keeps `bambuddy.log` focused on lines that matter for incident triage without churning the 5 MB rotation window faster than it's useful. Filter anchors on the `" `+verb+` ` pattern uvicorn's format string guarantees, so a literal `"POST"` substring inside a URL (e.g. `GET /api/posts/POST_123`) cannot false-match. The filter lives in its own module so the test suite can import it without pulling in `main.py`'s entire startup graph. 13 new tests in `test_logging_filters.py` cover all four write verbs being kept, GET/HEAD/OPTIONS being dropped, two URL-contains-verb-substring false-match guards, empty/unrelated-line/idempotency edge cases. Output now looks like `2026-04-26 09:23:14,690 INFO [uvicorn.access] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200` — one `grep "POST.*stop"` away from "who triggered this".
|
||||
|
||||
- **Spool auto-assign hit `IntegrityError` on Postgres when AMS pushes arrived in quick succession** — Bambu MQTT can deliver two `ams_data` push frames for the same printer ~30 ms apart (observed on H2D + dual AMS at K-profile-load / RFID-read boundaries). Each frame triggers `on_ams_change` in `backend/app/main.py`, whose auto-assign block reads `(printer_id, ams_id, tray_id)`, decides "no existing assignment", and INSERTs via `auto_assign_spool` — and the two callbacks raced in their respective sessions, both deciding to insert, with the second commit losing on `spool_assignment_printer_id_ams_id_tray_id_key`. SQLite's WAL serial-write semantics had been silently swallowing the race for ~7 weeks since the spool-assignment feature shipped (latent in `ec82092b`); when optional Postgres support landed in `610431d6` and asyncpg started allowing true concurrent transactions, it surfaced as `WARNING [main] RFID spool auto-assign failed: ... duplicate key value violates unique constraint ...; DETAIL: Key (printer_id, ams_id, tray_id)=(1, 0, 0) already exists`. Added a per-printer `asyncio.Lock` (`_ams_assignment_locks` keyed by `printer_id`) wrapping the auto-assign critical section so two callbacks for the same printer serialise — by the time the second one's session runs `select(SpoolAssignment).where(...)`, the first's commit is visible and the early-return "existing assignment" branch fires instead of a duplicate INSERT. The Spoolman sync block further down in the same callback intentionally stays OUTSIDE the lock — it's network-bound and idempotent, so serialising it would block subsequent AMS callbacks for the duration of a remote roundtrip. Per-printer scope keeps unrelated printers fully parallel: one printer's slow assignment never blocks another's. The auto-unlink block above the assign block isn't wrapped because its DELETE/UPDATE operations don't have the same constraint surface; the assign-block lock is sufficient because the second callback's `select` will see the first's committed state. 5 new regression tests in `test_ams_assignment_lock.py` cover same-printer-same-lock identity, different-printers-different-lock isolation, second acquirer waits for first inside the lock (proves serialisation), different printers run truly in parallel under a held lock (proves per-printer scope), and an auto-cleanup fixture resets the module-level dict between tests so cross-test loop affinity bugs can't surface.
|
||||
|
||||
@@ -191,6 +191,18 @@ class PrinterManager:
|
||||
Persisted so the gate survives Bambuddy/printer restarts (#961): after Auto Off
|
||||
cycles the printer, the printer boots into IDLE with no memory of the previous
|
||||
finish, and without persistence the queue would bypass the confirmation prompt.
|
||||
|
||||
Also broadcasts an updated ``printer_status`` over the WebSocket (#1128).
|
||||
``awaiting_plate_clear`` is a Bambuddy-side flag — toggling it does not
|
||||
produce an MQTT push from the printer, so without an explicit broadcast
|
||||
any UI subscriber that's NOT the originating tab would stay stale until
|
||||
the next coincidental status refresh. The plate-clear button on the
|
||||
printer card disappeared "immediately" only because of an optimistic
|
||||
React Query cache update on the click path; clearing the flag through
|
||||
any other route (an admin script, a second tab, an automation that
|
||||
hits ``POST /printers/{id}/clear-plate`` directly) silently broke the
|
||||
UI without it. Centralised here so every current AND future caller is
|
||||
covered without each one having to remember to broadcast.
|
||||
"""
|
||||
if awaiting:
|
||||
self._awaiting_plate_clear.add(printer_id)
|
||||
@@ -200,6 +212,43 @@ class PrinterManager:
|
||||
# emits "coroutine was never awaited" warnings (e.g. in sync unit tests).
|
||||
if self._loop and self._loop.is_running():
|
||||
self._schedule_async(self._persist_awaiting_plate_clear(printer_id, awaiting))
|
||||
self._schedule_async(self._broadcast_status_change(printer_id))
|
||||
|
||||
async def _broadcast_status_change(self, printer_id: int) -> None:
|
||||
"""Emit a ``printer_status`` WebSocket update for this printer (#1128).
|
||||
|
||||
Used for state changes that don't come from MQTT — currently just the
|
||||
``awaiting_plate_clear`` flag, but any future Bambuddy-side flag added
|
||||
to ``printer_state_to_dict`` should plumb through here too. The
|
||||
existing MQTT-driven broadcast in ``main.on_printer_status_change``
|
||||
deduplicates on a status_key that intentionally excludes Bambuddy
|
||||
flags (so e.g. queue-state changes don't get echoed as printer
|
||||
events), which is precisely why those flags need their own emit.
|
||||
|
||||
Lazy-imports ``ws_manager`` to keep ``printer_manager`` clean of
|
||||
application-layer infra at module-import time — the broadcast is the
|
||||
only thing here that needs it.
|
||||
"""
|
||||
state = self.get_status(printer_id)
|
||||
if not state:
|
||||
# Printer disconnected or unknown — nothing to broadcast. The
|
||||
# next reconnect will produce a fresh status push anyway, so the
|
||||
# UI eventually catches up without us forcing a stale snapshot
|
||||
# on subscribers now.
|
||||
return
|
||||
try:
|
||||
from backend.app.core.websocket import ws_manager
|
||||
|
||||
await ws_manager.send_printer_status(
|
||||
printer_id,
|
||||
printer_state_to_dict(state, printer_id, self.get_model(printer_id)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to broadcast printer_status after Bambuddy-side state change for printer %d: %s",
|
||||
printer_id,
|
||||
e,
|
||||
)
|
||||
|
||||
async def _persist_awaiting_plate_clear(self, printer_id: int, awaiting: bool):
|
||||
from backend.app.core.database import async_session
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Regression tests for ``PrinterManager._broadcast_status_change`` and
|
||||
its wiring from ``set_awaiting_plate_clear`` (#1128).
|
||||
|
||||
The bug: ``awaiting_plate_clear`` is a Bambuddy-side flag, so toggling it
|
||||
doesn't produce an MQTT push from the printer. Before the fix,
|
||||
``set_awaiting_plate_clear()`` mutated state and persisted to DB but never
|
||||
notified WebSocket subscribers. The plate-clear button on the printer card
|
||||
disappeared "immediately" only because of an optimistic React Query cache
|
||||
update on the click path; any other caller (admin script, second tab, an
|
||||
automation that hits ``POST /printers/{id}/clear-plate``) silently left
|
||||
the UI stale until the next coincidental status refresh.
|
||||
|
||||
These tests pin the contract: every flip of the flag must schedule a
|
||||
``printer_status`` broadcast, and the broadcast must carry the new flag
|
||||
value so subscribers see the right state without polling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.printer_manager import PrinterManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager():
|
||||
"""Fresh manager per test; the awaiting-plate-clear set is per-instance."""
|
||||
return PrinterManager()
|
||||
|
||||
|
||||
def _fake_state(**overrides):
|
||||
"""Minimal stand-in for a ``PrinterState`` — only the attributes
|
||||
``printer_state_to_dict`` reads. We use a SimpleNamespace rather than
|
||||
constructing a real PrinterState so this test stays fast and doesn't
|
||||
couple to the (large, evolving) PrinterState dataclass shape."""
|
||||
base = {
|
||||
"connected": True,
|
||||
"state": "FINISH",
|
||||
"raw_data": {},
|
||||
"progress": 100.0,
|
||||
}
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
class TestSchedulingFromSetAwaitingPlateClear:
|
||||
"""The hook from the public flag-mutation method into the broadcast."""
|
||||
|
||||
def test_schedules_broadcast_when_loop_running(self, manager):
|
||||
"""When a real event loop is attached, every call to
|
||||
``set_awaiting_plate_clear`` must enqueue both the persistence
|
||||
coroutine and the broadcast coroutine. Both are needed: persist
|
||||
survives restarts, broadcast notifies live subscribers."""
|
||||
manager._loop = MagicMock()
|
||||
manager._loop.is_running.return_value = True
|
||||
|
||||
with patch.object(manager, "_schedule_async") as scheduled:
|
||||
manager.set_awaiting_plate_clear(7, True)
|
||||
|
||||
# Two coroutines: persist + broadcast. Order doesn't matter.
|
||||
assert scheduled.call_count == 2
|
||||
|
||||
def test_does_not_schedule_when_no_loop_attached(self, manager):
|
||||
"""Sync unit-test path (no loop attached): nothing must be
|
||||
scheduled, otherwise Python emits 'coroutine was never awaited'
|
||||
runtime warnings and the test suite goes red on harmless flag
|
||||
twiddling."""
|
||||
manager._loop = None
|
||||
|
||||
with patch.object(manager, "_schedule_async") as scheduled:
|
||||
manager.set_awaiting_plate_clear(7, True)
|
||||
|
||||
scheduled.assert_not_called()
|
||||
|
||||
def test_does_not_schedule_when_loop_not_running(self, manager):
|
||||
"""A loop attached-but-stopped is the same situation as no loop —
|
||||
scheduling onto a dead loop would never fire."""
|
||||
manager._loop = MagicMock()
|
||||
manager._loop.is_running.return_value = False
|
||||
|
||||
with patch.object(manager, "_schedule_async") as scheduled:
|
||||
manager.set_awaiting_plate_clear(7, True)
|
||||
|
||||
scheduled.assert_not_called()
|
||||
|
||||
def test_both_true_and_false_flips_schedule_broadcast(self, manager):
|
||||
"""The bug only became visible on ``False`` flips (clear), but a
|
||||
regression that broadcasts only on ``True`` would re-introduce
|
||||
the original symptom for any future flag mutation that goes
|
||||
``False → True`` outside the printer-card optimistic-update
|
||||
path. Make both directions a contract."""
|
||||
manager._loop = MagicMock()
|
||||
manager._loop.is_running.return_value = True
|
||||
|
||||
with patch.object(manager, "_schedule_async") as scheduled:
|
||||
manager.set_awaiting_plate_clear(7, True)
|
||||
scheduled.reset_mock()
|
||||
manager.set_awaiting_plate_clear(7, False)
|
||||
|
||||
# Each flip = persist + broadcast = 2 calls.
|
||||
assert scheduled.call_count == 2
|
||||
|
||||
|
||||
class TestBroadcastStatusChange:
|
||||
"""The broadcast coroutine itself."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emits_ws_update_when_state_present(self, manager):
|
||||
"""Happy path: printer has a known status, broadcast goes out
|
||||
with the dict produced by ``printer_state_to_dict``."""
|
||||
state = _fake_state()
|
||||
with (
|
||||
patch.object(manager, "get_status", return_value=state),
|
||||
patch.object(manager, "get_model", return_value="P1S"),
|
||||
patch(
|
||||
"backend.app.core.websocket.ws_manager.send_printer_status",
|
||||
new_callable=AsyncMock,
|
||||
) as send_status,
|
||||
patch(
|
||||
"backend.app.services.printer_manager.printer_state_to_dict",
|
||||
return_value={"id": 7, "awaiting_plate_clear": False},
|
||||
) as to_dict,
|
||||
):
|
||||
await manager._broadcast_status_change(7)
|
||||
|
||||
send_status.assert_awaited_once()
|
||||
# First positional arg is the printer ID, second is the status dict.
|
||||
printer_id_arg, payload_arg = send_status.await_args.args
|
||||
assert printer_id_arg == 7
|
||||
assert payload_arg == {"id": 7, "awaiting_plate_clear": False}
|
||||
# Verify the dict was built from the right inputs (state + id + model).
|
||||
to_dict.assert_called_once_with(state, 7, "P1S")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_status_unknown(self, manager):
|
||||
"""Printer not connected / unknown ID → no point broadcasting a
|
||||
snapshot we don't have. A future reconnect will produce a fresh
|
||||
status push anyway, so we'd only be forcing a stale or bogus
|
||||
payload onto subscribers right now."""
|
||||
with (
|
||||
patch.object(manager, "get_status", return_value=None),
|
||||
patch(
|
||||
"backend.app.core.websocket.ws_manager.send_printer_status",
|
||||
new_callable=AsyncMock,
|
||||
) as send_status,
|
||||
):
|
||||
await manager._broadcast_status_change(999)
|
||||
|
||||
send_status.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swallows_websocket_errors(self, manager):
|
||||
"""The broadcast is a courtesy, not a correctness path — if the
|
||||
WS layer is down, the flag is already mutated in-memory and
|
||||
persisted. Letting an exception bubble out of
|
||||
``_broadcast_status_change`` would surface as an
|
||||
``Exception in scheduled callback`` traceback in the log AND
|
||||
prevent the persistence coroutine from completing if both were
|
||||
gathered together. Swallow + warn instead."""
|
||||
with (
|
||||
patch.object(manager, "get_status", return_value=_fake_state()),
|
||||
patch.object(manager, "get_model", return_value="P1S"),
|
||||
patch(
|
||||
"backend.app.services.printer_manager.printer_state_to_dict",
|
||||
return_value={"id": 7},
|
||||
),
|
||||
patch(
|
||||
"backend.app.core.websocket.ws_manager.send_printer_status",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("websocket layer unavailable"),
|
||||
),
|
||||
):
|
||||
# Must not raise.
|
||||
await manager._broadcast_status_change(7)
|
||||
|
||||
|
||||
class TestEndToEndUnderRunningLoop:
|
||||
"""Verify the full flow under a real running event loop — schedule
|
||||
→ broadcast → ws_manager.send_printer_status — without mocking
|
||||
``_schedule_async``. Catches regressions where individual pieces
|
||||
pass but the wiring breaks (e.g. ``_schedule_async`` swallowing the
|
||||
broadcast coroutine)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_false_eventually_emits_broadcast(self, manager):
|
||||
"""Reproduces the #1128 fix path end-to-end: set the flag to
|
||||
False under a live loop, give the scheduler a tick, the
|
||||
ws broadcast must have fired with the new payload."""
|
||||
loop = asyncio.get_running_loop()
|
||||
manager._loop = loop
|
||||
# Pretend the printer has been seen — without a state present
|
||||
# the broadcast short-circuits before reaching ws_manager.
|
||||
manager._awaiting_plate_clear.add(7)
|
||||
|
||||
with (
|
||||
patch.object(manager, "get_status", return_value=_fake_state()),
|
||||
patch.object(manager, "get_model", return_value="P1S"),
|
||||
patch(
|
||||
"backend.app.services.printer_manager.printer_state_to_dict",
|
||||
return_value={"id": 7, "awaiting_plate_clear": False},
|
||||
),
|
||||
patch(
|
||||
"backend.app.core.websocket.ws_manager.send_printer_status",
|
||||
new_callable=AsyncMock,
|
||||
) as send_status,
|
||||
# Persistence path opens a DB session; stub it out so this
|
||||
# stays a pure unit test.
|
||||
patch.object(manager, "_persist_awaiting_plate_clear", new_callable=AsyncMock),
|
||||
):
|
||||
manager.set_awaiting_plate_clear(7, False)
|
||||
# Yield repeatedly so run_coroutine_threadsafe has a chance
|
||||
# to land its scheduled coroutine on this loop.
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
send_status.assert_awaited()
|
||||
printer_id_arg, payload_arg = send_status.await_args.args
|
||||
assert printer_id_arg == 7
|
||||
assert payload_arg["awaiting_plate_clear"] is False
|
||||
Reference in New Issue
Block a user