From 45f0ba47dbadde0aca0b7de9293efa4ed8aba0ec Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 7 Jul 2026 10:07:47 +0200 Subject: [PATCH] fix(scheduler): default require_plate_clear to False to match schema/UI (#1865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + backend/app/services/print_scheduler.py | 10 +- .../tests/unit/test_scheduler_clear_plate.py | 152 ++++++++++++++++++ 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d0625dd..23c929319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b2] - Unreleased ### Fixed +- **Queued prints never dispatched to FINISH-state printers when "Require plate-clear confirmation" was disabled (#1865, reporter @PurseChicken)** — On installs that had never explicitly saved the plate-clear setting, the scheduler enforced the plate-clear gate even though the Settings toggle (and the printer card's plate badge/button) correctly showed it as OFF, so a finished printer sat at `awaiting_plate_clear=True` forever with no UI path to clear it and the queue item stuck in "Waiting". **Root cause (pinpointed by the reporter):** two code paths disagreed on the default. `SettingsSchema.require_plate_clear` defaults `False` and the entire frontend treats a missing value as off, but `print_scheduler.check_queue()` read the setting with `_get_bool_setting(..., default=True)` — and `_get_bool_setting` returns its default when no DB row exists. With no settings row, the UI showed the gate off while the scheduler kept it on. Both defaults shipped in the same commit (#752), so this was a latent inconsistency, not a #1625 regression. **Fix:** the scheduler now reads `require_plate_clear` with `default=False`, matching the schema and the UI. Added regression tests pinning the call-site default and the `_get_bool_setting` no-row behavior. - **Light theme: low-contrast washed-out text on status/warning banners and colored badges (#1909, reporter @AntonPalmqvist)** — The app was built dark-first: hundreds of hardcoded Tailwind semantic-color text utilities at light shades (`text-amber-400`, `text-blue-300`, `text-green-200`, etc.) had no `dark:` variant, so with `darkMode: 'class'` they applied in the light theme too — light-amber/blue/green text on pale `bg-*-500/10` tints or white cards, barely readable. The three reporter-cited spots were all this bug: the AMS **Drying** status banner (Printers page), the **Archives** no-3MF-thumbnail warning, and the **System/debug-logging** banner. Fixed app-wide by giving every affected semantic text/icon/tint a theme-aware pair — a readable darker shade in light theme (e.g. `text-amber-700`/`-800`/`-900`) with the original light shade pinned to `dark:` so the dark theme is unchanged — across ~100 files (banners, status pills, permission/role badges, method chips, validation errors, stat-card icons, delete-action hovers, and more). The custom CSS-variable `bambu-*` palette (which already self-corrects per theme) and the SpoolBuddy kiosk (dark-theme-only) were deliberately left untouched. - **Sponsor toast ignored its 14-day cooldown and re-fired on every fresh session (#2477, reporter @pchulpjoost)** — The in-app supporter toast ("You've completed X prints with Bambuddy…") reappeared on essentially every visit instead of respecting the documented 14-day cross-family cooldown. **Root cause:** the backend owns the cooldown, but it only persists the cooldown anchor (`last_shown_at`) and the seen-milestone record inside `POST /sponsor-prompt/dismiss` — and the frontend hook only called `dismiss` from the "View supporters" CTA's `onClick`. A user who *saw* the toast but never clicked the CTA persisted no state at all. The per-tab `sessionStorage` guard hid the re-fire within a single browser session, but every fresh session (new tab, reopened browser, or another device — the reporter confirmed "Other OS/Browser has the same problem") re-ran the check against still-empty backend state and showed the same milestone again. To a user opening Bambuddy to check on a finished print, that looked like "every print." **Fix:** the hook now records the toast as shown the moment it renders — it POSTs `/dismiss` (whose documented job is exactly "anchor the 14-day cooldown and record the milestone as shown") immediately after `showPersistentToast`, instead of waiting for a CTA click. Being *displayed* is what arms the cooldown; clicking the CTA stays optional and just navigates to the sponsors page. `frontend/src/hooks/useSponsorPrompt.ts` only; backend cooldown logic unchanged. - **Windows installer: fresh install fails to start — "connection refused", nothing listening on port 8000 (#2474, reporter @fangme)** — On a clean Windows 10 machine, installing the `.exe` left the Bambuddy service showing "running" in services.msc but the dashboard refused every connection (localhost, IP, and hostname), and `netstat -ano` showed nothing on :8000. **Root cause — bottom of a 100k-line `service-stderr.log` traceback:** `init_db()` → SQLAlchemy async engine → `greenlet_spawn` → `ValueError: the greenlet library is required to use this function. DLL load failed while importing _greenlet: The specified module could not be found.` The 100k repeated `merged_lifespan` frames above it are just FastAPI's nested-lifespan stack unwinding — noise. The real failure: greenlet's `_greenlet.pyd` couldn't load, SQLAlchemy's async engine couldn't start, the FastAPI lifespan raised, and uvicorn **never bound the port** — so the supervised process stays up (NSSM sees it running) while the app itself has crashed on startup. **Why greenlet specifically:** the Win32 error 126 ("The specified module could not be found") on a `.pyd` that pip installed successfully means the module is present but a *dependency DLL* is missing. greenlet's extension is **C++** and needs `vcruntime140_1.dll` (table-based exception handling); the python.org **embeddable** distribution the installer bundles ships `vcruntime140.dll` but **not** `vcruntime140_1.dll`. `python313.dll` is pure C and only needs the former, which is exactly why python.exe starts and runs all the way to `init_db` before greenlet is the first thing to need the missing C++ runtime. On machines that already have the VC++ 2015-2022 redistributable installed (the maintainer's box, CI runners) `vcruntime140_1.dll` is in System32 and everything works — masking the bug until a truly fresh Win10 box hit it. **Fix:** the installer build now ships the C++ runtime app-locally — `vcruntime140_1.dll` and `msvcp140.dll` are staged next to `python.exe` (where `vcruntime140.dll` already lives), sourced from a vendored copy if present or the build runner's System32 otherwise, failing the build loudly if neither has them. `installers/windows/build.py` only; the Inno Setup `[Files]` step already copies `staging\python\*` recursively so the new DLLs are packaged automatically. **Workaround for the current build:** install the "Microsoft Visual C++ 2015-2022 Redistributable (x64)" from Microsoft, then restart the Bambuddy service — that puts `vcruntime140_1.dll` in System32 where the embedded Python finds it. diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 73b315bc4..b7c5411d1 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -234,8 +234,14 @@ class PrintScheduler: ) 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: # No pending items — still check auto-drying on idle printers diff --git a/backend/tests/unit/test_scheduler_clear_plate.py b/backend/tests/unit/test_scheduler_clear_plate.py index 1af5bf78b..b7b4e4a30 100644 --- a/backend/tests/unit/test_scheduler_clear_plate.py +++ b/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 +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: """Test queue check logging when pending items are found (#374)."""