瀏覽代碼

fix(scheduler): default require_plate_clear to False to match schema/UI (#1865)

check_queue() read the plate-clear setting with _get_bool_setting(default=True),
but SettingsSchema.require_plate_clear defaults False and the whole frontend
treats a missing value as off. Since _get_bool_setting returns its default when
no DB row exists, installs that never saved the setting enforced the plate-clear
gate the UI showed as disabled — FINISH-state printers never dispatched and no UI
control existed to clear awaiting_plate_clear. Read the setting with default=False
so the enforced behavior matches the schema and the toggle. Both defaults shipped
together in #752; this aligns them.
maziggy 2 月之前
父節點
當前提交
45f0ba47db
共有 3 個文件被更改,包括 161 次插入2 次删除
  1. 1 0
      CHANGELOG.md
  2. 8 2
      backend/app/services/print_scheduler.py
  3. 152 0
      backend/tests/unit/test_scheduler_clear_plate.py

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 8 - 2
backend/app/services/print_scheduler.py

@@ -234,8 +234,14 @@ class PrintScheduler:
                 )
                 )
             items = list(result.scalars().all())
             items = list(result.scalars().all())
 
 
-            # Read plate-clear setting once per queue check
-            require_plate_clear = await self._get_bool_setting(db, "require_plate_clear", default=True)
+            # Read plate-clear setting once per queue check. Default MUST be
+            # False to match the schema (SettingsSchema.require_plate_clear
+            # defaults False) and the frontend (toggle + card badge both treat a
+            # missing value as off). When no settings row exists, a True default
+            # here re-enabled the plate-clear gate the UI showed as disabled,
+            # blocking dispatch to FINISH-state printers forever with no UI path
+            # to clear it (#1865).
+            require_plate_clear = await self._get_bool_setting(db, "require_plate_clear", default=False)
 
 
             if not items:
             if not items:
                 # No pending items — still check auto-drying on idle printers
                 # No pending items — still check auto-drying on idle printers

+ 152 - 0
backend/tests/unit/test_scheduler_clear_plate.py

@@ -280,6 +280,158 @@ class TestSchedulerIdleCheckWithPlateCleared:
         assert scheduler._is_printer_idle(1, require_plate_clear=False) is True
         assert scheduler._is_printer_idle(1, require_plate_clear=False) is True
 
 
 
 
+class TestPlateGateDefaultsOffWhenUnset:
+    """#1865: with no require_plate_clear row in the settings table, the plate-clear
+    gate must default OFF — matching SettingsSchema.require_plate_clear (default False)
+    and the frontend (toggle + card badge both treat a missing value as off). The
+    scheduler previously read this setting with default=True, so on installs that had
+    never saved the setting the gate stayed enforced while the UI showed it disabled —
+    FINISH-state printers never dispatched and there was no UI control to clear the flag.
+    """
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @pytest.mark.asyncio
+    async def test_get_bool_setting_honors_false_default_when_row_absent(self, scheduler):
+        """_get_bool_setting must return the caller's default when the key has no row."""
+        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+        import backend.app.models  # noqa: F401
+        from backend.app.core.database import Base
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+        async with session_maker() as db:
+            # No Settings row seeded → the caller's default decides the value.
+            assert await scheduler._get_bool_setting(db, "require_plate_clear", default=False) is False
+        await engine.dispose()
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_check_queue_reads_plate_clear_setting_with_default_false(self, mock_pm, scheduler):
+        """The per-check read of require_plate_clear must pass default=False (#1865).
+
+        Guards the exact regression: a True default here re-enabled the gate the
+        schema/UI treat as off when no settings row exists.
+        """
+        scheduler._get_bool_setting = AsyncMock(return_value=False)
+        scheduler._check_auto_drying = AsyncMock()
+
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = []  # empty queue → early return
+
+        with patch("backend.app.services.print_scheduler.async_session") as mock_session_ctx:
+            mock_db = AsyncMock()
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+            await scheduler.check_queue()
+
+        plate_calls = [
+            c
+            for c in scheduler._get_bool_setting.call_args_list
+            if len(c.args) >= 2 and c.args[1] == "require_plate_clear"
+        ]
+        assert plate_calls, "check_queue did not read the require_plate_clear setting"
+        assert plate_calls[0].kwargs.get("default") is False, (
+            "require_plate_clear must be read with default=False to match the schema/UI (#1865)"
+        )
+
+
+class TestPlateGateEndToEnd:
+    """#1865 end-to-end: chain the REAL settings DB read (_get_bool_setting) into the
+    REAL idle/dispatch gate (_is_printer_idle) for every setting state, so the wiring
+    the bug lived in (settings row/absence -> require_plate_clear -> dispatch gate) is
+    verified without mocking the value under test.
+    """
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    async def _read_setting(self, scheduler, row_value):
+        """Build a real in-memory settings DB (optionally with a require_plate_clear
+        row) and return what the scheduler's call site actually reads."""
+        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+        import backend.app.models  # noqa: F401
+        from backend.app.core.database import Base
+        from backend.app.models.settings import Settings
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+        async with session_maker() as db:
+            if row_value is not None:
+                db.add(Settings(key="require_plate_clear", value=row_value))
+                await db.commit()
+            # Mirror the exact call site in check_queue (print_scheduler.py).
+            value = await scheduler._get_bool_setting(db, "require_plate_clear", default=False)
+        await engine.dispose()
+        return value
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "row_value, expected_gate",
+        [
+            (None, False),  # fresh install / never saved -> #1865 case -> gate OFF
+            ("false", False),  # explicitly disabled -> gate OFF
+            ("true", True),  # explicitly enabled -> gate ON
+            ("True", True),  # case-insensitive parse
+        ],
+    )
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_finish_awaiting_dispatch_eligibility_matches_setting(
+        self, mock_pm, scheduler, row_value, expected_gate
+    ):
+        """A FINISH printer with the awaiting flag raised is dispatch-eligible IFF the
+        gate is off. Reads the setting from a real DB, then feeds it to the real gate."""
+        require_plate_clear = await self._read_setting(scheduler, row_value)
+        assert require_plate_clear is expected_gate
+
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FINISH")
+        mock_pm.is_awaiting_plate_clear.return_value = True  # bed potentially fouled
+
+        is_idle = scheduler._is_printer_idle(1, require_plate_clear)
+        # Gate OFF -> idle (dispatches). Gate ON -> not idle (waits for ack).
+        assert is_idle is (not expected_gate)
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_enabled_gate_releases_after_plate_cleared(self, mock_pm, scheduler):
+        """With the setting explicitly ON, clearing the plate (awaiting -> False) must
+        flip the FINISH printer to dispatch-eligible — the full block-then-release cycle."""
+        require_plate_clear = await self._read_setting(scheduler, "true")
+        assert require_plate_clear is True
+
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FINISH")
+
+        # Before ack: awaiting -> blocked.
+        mock_pm.is_awaiting_plate_clear.return_value = True
+        assert scheduler._is_printer_idle(1, require_plate_clear) is False
+
+        # After "Mark plate as cleared" (route sets flag False): dispatch-eligible.
+        mock_pm.is_awaiting_plate_clear.return_value = False
+        assert scheduler._is_printer_idle(1, require_plate_clear) is True
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_running_never_idle_regardless_of_gate(self, mock_pm, scheduler):
+        """Sanity: a RUNNING printer is never dispatch-eligible, gate on or off."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="RUNNING")
+        mock_pm.is_awaiting_plate_clear.return_value = False
+        for gate in (True, False):
+            assert scheduler._is_printer_idle(1, gate) is False
+
+
 class TestSchedulerQueueCheckLogging:
 class TestSchedulerQueueCheckLogging:
     """Test queue check logging when pending items are found (#374)."""
     """Test queue check logging when pending items are found (#374)."""
 
 

Some files were not shown because too many files changed in this diff