mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Single failure on a printer with require_previous_success queue items
permanently skipped every downstream + every new item — the
_check_previous_success lookback always walked back to the original
failed row (skipped is excluded from the lookback), and no code path
could dismiss that failure.
Three pieces:
1. PrintQueueItem.gate_acknowledged Boolean column (default False).
SQLite/Postgres-safe ALTER, dialect-branched DEFAULT.
2. _check_previous_success skips rows where gate_acknowledged=True so
acknowledged failures walk past the lookback. Fresh post-resume
failures still gate independently.
3. POST /api/v1/queue/printer/{printer_id}/resume — gated on
QUEUE_UPDATE_ALL — acknowledges failed/aborted items for that
printer AND restores items where
status='skipped' AND error_message='Previous print failed or was
aborted' back to pending in one transaction. Returns
{acknowledged, restored}.
Frontend banner above the active Queue tab surfaces blocked printers,
fires a warning-variant ConfirmModal, and shows a precise toast on
success.
219 lines
9.0 KiB
Python
219 lines
9.0 KiB
Python
"""Tests for `PrintScheduler._check_previous_success` (#1667).
|
|
|
|
Pre-fix behaviour: the lookback `.in_([...])` list excluded `cancelled` and
|
|
included `skipped`, so a single user-cancelled print blocked every downstream
|
|
item with `require_previous_success=True` permanently (the reporter saw 18
|
|
items blocked over 3 days from one cancellation, because each new skip
|
|
became the next skip's "failed predecessor").
|
|
|
|
Post-fix behaviour:
|
|
- `cancelled` is a neutral outcome → returns True (a deliberate user action
|
|
is not a print failure)
|
|
- `skipped` is excluded from the lookback → an already-skipped item never
|
|
counts as a predecessor; the query walks back to the most recent real
|
|
print attempt
|
|
- `failed` and `aborted` still gate as before
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
from backend.app.services.print_scheduler import PrintScheduler
|
|
|
|
|
|
@pytest.fixture
|
|
def scheduler():
|
|
return PrintScheduler()
|
|
|
|
|
|
@pytest.fixture
|
|
def queue_factory(db_session, printer_factory):
|
|
"""Helper to drop completed/failed/cancelled/skipped queue items in order.
|
|
|
|
Each call assigns a monotonically increasing `completed_at` so the
|
|
scheduler's `ORDER BY completed_at DESC` reliably picks the latest as
|
|
the predecessor. `printer_id` is shared so all items count.
|
|
"""
|
|
base_time = datetime(2026, 6, 6, 12, 0, 0, tzinfo=timezone.utc)
|
|
counter = {"n": 0}
|
|
printer_holder: dict = {}
|
|
|
|
async def _make_printer():
|
|
if "p" not in printer_holder:
|
|
printer_holder["p"] = await printer_factory()
|
|
return printer_holder["p"]
|
|
|
|
async def _add(
|
|
status: str,
|
|
error_message: str | None = None,
|
|
gate_acknowledged: bool = False,
|
|
) -> PrintQueueItem:
|
|
printer = await _make_printer()
|
|
counter["n"] += 1
|
|
item = PrintQueueItem(
|
|
printer_id=printer.id,
|
|
status=status,
|
|
error_message=error_message,
|
|
completed_at=base_time + timedelta(minutes=counter["n"]),
|
|
require_previous_success=True,
|
|
gate_acknowledged=gate_acknowledged,
|
|
)
|
|
db_session.add(item)
|
|
await db_session.commit()
|
|
await db_session.refresh(item)
|
|
return item
|
|
|
|
async def _add_pending() -> PrintQueueItem:
|
|
printer = await _make_printer()
|
|
item = PrintQueueItem(
|
|
printer_id=printer.id,
|
|
status="pending",
|
|
require_previous_success=True,
|
|
)
|
|
db_session.add(item)
|
|
await db_session.commit()
|
|
await db_session.refresh(item)
|
|
return item
|
|
|
|
return {"add": _add, "add_pending": _add_pending}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_previous_item_returns_true(scheduler, db_session, queue_factory):
|
|
"""First item in the queue has no predecessor → always passes."""
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_previous_completed_returns_true(scheduler, db_session, queue_factory):
|
|
await queue_factory["add"]("completed")
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_previous_failed_returns_false(scheduler, db_session, queue_factory):
|
|
await queue_factory["add"]("failed")
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_previous_aborted_returns_false(scheduler, db_session, queue_factory):
|
|
"""A printer-detected abort (e.g. clogged nozzle) is a real failure → blocks."""
|
|
await queue_factory["add"]("aborted")
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_previous_cancelled_returns_true_bug_a(scheduler, db_session, queue_factory):
|
|
"""#1667 bug A: user cancellation is deliberate, not a failure → passes."""
|
|
await queue_factory["add"]("cancelled")
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skipped_predecessor_is_walked_past_bug_b(scheduler, db_session, queue_factory):
|
|
"""#1667 bug B: a skipped item is not an attempt — query walks back to the
|
|
most recent real outcome instead of treating skipped as failed."""
|
|
await queue_factory["add"]("completed") # real predecessor that should be found
|
|
await queue_factory["add"]("skipped", "Previous print failed or was aborted")
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_only_skipped_history_returns_true(scheduler, db_session, queue_factory):
|
|
"""Edge case: every prior item is skipped → no real predecessor found,
|
|
returns True (first-in-queue semantics)."""
|
|
await queue_factory["add"]("skipped", "Previous print failed or was aborted")
|
|
await queue_factory["add"]("skipped", "Previous print failed or was aborted")
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cascade_reporters_scenario(scheduler, db_session, queue_factory):
|
|
"""The exact #1667 reporter scenario: failed → cancelled → skipped → pending.
|
|
|
|
Pre-fix: pending blocked because the buggy lookback walked past the
|
|
cancelled item (excluded) and the prior skipped item (included), found
|
|
the failed item, and returned False.
|
|
Post-fix: cancelled is the predecessor (skipped is excluded; cancelled
|
|
is included and passes), pending dispatches.
|
|
"""
|
|
await queue_factory["add"]("failed")
|
|
await queue_factory["add"]("cancelled")
|
|
await queue_factory["add"]("skipped", "Previous print failed or was aborted")
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_then_cancelled_still_passes(scheduler, db_session, queue_factory):
|
|
"""User cancelled after a failure → most recent action wins. The cancellation
|
|
is the user explicitly choosing to move on, so dispatching the next item
|
|
respects their intent."""
|
|
await queue_factory["add"]("failed")
|
|
await queue_factory["add"]("cancelled")
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_completed_then_failed_blocks(scheduler, db_session, queue_factory):
|
|
"""Regression guard: a real failure after a previously-successful print
|
|
still gates downstream items. Only the MOST RECENT outcome matters."""
|
|
await queue_factory["add"]("completed")
|
|
await queue_factory["add"]("failed")
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is False
|
|
|
|
|
|
# ---- #1818: per-printer Resume-after-failure gate acknowledgement ----
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acknowledged_failure_is_excluded(scheduler, db_session, queue_factory):
|
|
"""The reporter scenario: failure with gate_acknowledged=True must NOT
|
|
block. Without the acknowledge filter, a single failure poisons every
|
|
later require_previous_success item forever."""
|
|
await queue_factory["add"]("failed", gate_acknowledged=True)
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acknowledged_aborted_is_excluded(scheduler, db_session, queue_factory):
|
|
await queue_factory["add"]("aborted", gate_acknowledged=True)
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fresh_failure_after_ack_still_blocks(scheduler, db_session, queue_factory):
|
|
"""Per-item acknowledgement is independent — a NEW failure after the
|
|
user resumed the queue must re-gate downstream items so they don't
|
|
silently steamroll past a real problem."""
|
|
await queue_factory["add"]("failed", gate_acknowledged=True) # the old one
|
|
await queue_factory["add"]("failed", gate_acknowledged=False) # fresh post-resume
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acknowledged_failure_walks_back_to_completed(scheduler, db_session, queue_factory):
|
|
"""After acknowledging the failure, the next real predecessor (a
|
|
completed print prior to the failure) governs the gate."""
|
|
await queue_factory["add"]("completed")
|
|
await queue_factory["add"]("failed", gate_acknowledged=True)
|
|
pending = await queue_factory["add_pending"]()
|
|
assert await scheduler._check_previous_success(db_session, pending) is True
|