Просмотр исходного кода

fix(scheduler): raise plate-clear gate for every terminal status (#1171)

  The plate-clear gate added in #961 was raised only when a print ended
  with status completed or failed. Aborted prints (printer self-abort
  or a user stopping the print from the printer's own touchscreen) and
  cancelled prints (user stopping via the Bambuddy queue UI) did NOT
  raise the flag, so the queue scheduler dispatched the next pending
  item ~2 seconds later onto a fouled bed.

  The reporter saw two prints (P1P + P1S) auto-start onto fouled beds
  within seconds of touchscreen-aborts, and explicitly flagged the
  risk of damage to the printer. A third printer behaved correctly
  because its previous print had ended "completed" — the asymmetry he
  noticed was the gate working for one terminal status and not the
  other three.

  Touchscreen-aborts are particularly important to gate. Bambuddy's
  existing "user stopped via UI" override (which translates aborted
  to cancelled when _user_stopped_printers is populated) only fires
  for stops through the Bambuddy queue UI; a touchscreen stop reports
  aborted straight through.

  The original code comment claimed user-cancelled prints don't need a
  plate-clear ack because "nothing printed on the bed". That only
  holds if you cancel right at layer 1; a cancel at hour 11 of a
  12-hour print leaves a fully fouled bed.

  The gate is user-clearable on the Printers page, so worst case a
  user who cancels at layer 1 clicks "Clear Plate" once — that's a
  non-issue compared to auto-dispatching onto material.

  Regression coverage in test_print_lifecycle.py::TestPlateClearGate:
  parametrised across all 4 terminal statuses asserting
  set_awaiting_plate_clear(printer_id, True) is called for each, plus
  a defence-in-depth test that an unrecognised future status string
  never silently raises the gate.
maziggy 3 недель назад
Родитель
Сommit
25eab96817
3 измененных файлов с 119 добавлено и 4 удалено
  1. 0 0
      CHANGELOG.md
  2. 10 4
      backend/app/main.py
  3. 109 0
      backend/tests/integration/test_print_lifecycle.py

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 10 - 4
backend/app/main.py

@@ -2654,11 +2654,17 @@ async def on_print_complete(printer_id: int, data: dict):
         data = {**data, "status": "cancelled"}
     _user_stopped_printers.discard(printer_id)
 
-    # Raise the plate-clear gate for queued dispatch (#961). Only for completed/failed —
-    # user-cancelled prints don't require a plate-clear ack (nothing printed on the bed).
-    # Persisted to DB so the gate survives Auto Off power cycles and Bambuddy restarts.
+    # Raise the plate-clear gate for queued dispatch (#961). Any terminal status
+    # may have left material on the bed: a user can cancel ten hours into a
+    # twelve-hour print, a printer can self-abort mid-job after a clog, and a
+    # touchscreen-stop reports `aborted` rather than `cancelled` because
+    # `_user_stopped_printers` is only populated when the user stops via the
+    # Bambuddy queue UI. Earlier code raised the flag only for completed/failed,
+    # which auto-dispatched the next queued print onto a fouled bed two seconds
+    # after a touchscreen-abort (#1171). Persisted to DB so the gate survives
+    # Auto Off power cycles and Bambuddy restarts.
     _final_status = data.get("status", "completed")
-    if _final_status in ("completed", "failed"):
+    if _final_status in ("completed", "failed", "aborted", "cancelled"):
         printer_manager.set_awaiting_plate_clear(printer_id, True)
 
     # MQTT relay - publish print complete

+ 109 - 0
backend/tests/integration/test_print_lifecycle.py

@@ -59,6 +59,115 @@ class TestPrintStartLogic:
         assert not errors, f"Import shadowing error: {capture_logs.format_errors()}"
 
 
+class TestPlateClearGate:
+    """The plate-clear gate (#961) blocks the queue from auto-dispatching the
+    next print until the user acknowledges the bed was cleared. The gate must
+    be raised on every terminal status that could have left material on the
+    bed — including aborted (printer self-abort or touchscreen stop) and
+    cancelled (user stopped via Bambuddy queue UI). #1171: prior code only
+    raised the flag for completed/failed, so an aborted print auto-dispatched
+    the next queue item onto a fouled bed two seconds later."""
+
+    @staticmethod
+    def _setup_mocks(stack):
+        mock_session_maker = stack.enter_context(patch("backend.app.main.async_session"))
+        stack.enter_context(patch("backend.app.main.notification_service")).on_print_complete = AsyncMock()
+        stack.enter_context(patch("backend.app.main.smart_plug_manager")).on_print_complete = AsyncMock()
+        mock_ws = stack.enter_context(patch("backend.app.main.ws_manager"))
+        mock_ws.send_print_complete = AsyncMock()
+        mock_ws.broadcast = AsyncMock()
+        stack.enter_context(patch("backend.app.main.mqtt_relay")).on_print_complete = AsyncMock()
+        mock_pm = stack.enter_context(patch("backend.app.main.printer_manager"))
+        mock_pm.get_printer.return_value = None
+        # Real method under test — track each call so the test can assert on it.
+        mock_pm.set_awaiting_plate_clear = MagicMock()
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock()
+        mock_session.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))
+        mock_session_maker.return_value = mock_session
+        return mock_pm
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "status",
+        ["completed", "failed", "aborted", "cancelled"],
+        ids=["completed", "failed", "aborted-1171", "cancelled-1171"],
+    )
+    async def test_plate_clear_gate_raised_for_every_terminal_status(self, status):
+        """Regression for #1171. Every terminal status that can leave material
+        on the bed must raise the gate. Pre-fix the gate was raised only for
+        completed/failed, so aborted (printer touchscreen stop, self-abort) and
+        cancelled (Bambuddy queue stop) auto-dispatched the next queue item
+        onto a fouled bed."""
+        from contextlib import ExitStack
+
+        tasks_before = set(asyncio.all_tasks())
+
+        with ExitStack() as stack:
+            mock_pm = self._setup_mocks(stack)
+
+            from backend.app.main import on_print_complete
+
+            await on_print_complete(
+                1,
+                {
+                    "status": status,
+                    "filename": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                    "timelapse_was_active": False,
+                },
+            )
+
+            for task in asyncio.all_tasks() - tasks_before:
+                task.cancel()
+                try:
+                    await task
+                except (asyncio.CancelledError, Exception):
+                    pass
+
+        mock_pm.set_awaiting_plate_clear.assert_any_call(1, True)
+
+    @pytest.mark.asyncio
+    async def test_plate_clear_gate_not_raised_for_unknown_status(self):
+        """Defence in depth: an unknown / not-terminal status string from a
+        future firmware revision must not silently raise the gate. The flag is
+        only meaningful when the print actually ended."""
+        from contextlib import ExitStack
+
+        tasks_before = set(asyncio.all_tasks())
+
+        with ExitStack() as stack:
+            mock_pm = self._setup_mocks(stack)
+
+            from backend.app.main import on_print_complete
+
+            await on_print_complete(
+                1,
+                {
+                    "status": "unknown_future_status",
+                    "filename": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                    "timelapse_was_active": False,
+                },
+            )
+
+            for task in asyncio.all_tasks() - tasks_before:
+                task.cancel()
+                try:
+                    await task
+                except (asyncio.CancelledError, Exception):
+                    pass
+
+        # The mock records every call; assert no True-call landed.
+        true_calls = [c for c in mock_pm.set_awaiting_plate_clear.call_args_list if c.args[1] is True]
+        assert true_calls == [], (
+            "Gate must not be raised for an unrecognised terminal status; "
+            f"set_awaiting_plate_clear({1}, True) was called {len(true_calls)} time(s)."
+        )
+
+
 class TestPrintCompleteLogic:
     """Test print complete callback logic."""
 

Некоторые файлы не были показаны из-за большого количества измененных файлов