| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218 |
- """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
|