test_check_previous_success.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. """Tests for `PrintScheduler._check_previous_success` (#1667).
  2. Pre-fix behaviour: the lookback `.in_([...])` list excluded `cancelled` and
  3. included `skipped`, so a single user-cancelled print blocked every downstream
  4. item with `require_previous_success=True` permanently (the reporter saw 18
  5. items blocked over 3 days from one cancellation, because each new skip
  6. became the next skip's "failed predecessor").
  7. Post-fix behaviour:
  8. - `cancelled` is a neutral outcome → returns True (a deliberate user action
  9. is not a print failure)
  10. - `skipped` is excluded from the lookback → an already-skipped item never
  11. counts as a predecessor; the query walks back to the most recent real
  12. print attempt
  13. - `failed` and `aborted` still gate as before
  14. """
  15. from __future__ import annotations
  16. from datetime import datetime, timedelta, timezone
  17. import pytest
  18. from backend.app.models.print_queue import PrintQueueItem
  19. from backend.app.services.print_scheduler import PrintScheduler
  20. @pytest.fixture
  21. def scheduler():
  22. return PrintScheduler()
  23. @pytest.fixture
  24. def queue_factory(db_session, printer_factory):
  25. """Helper to drop completed/failed/cancelled/skipped queue items in order.
  26. Each call assigns a monotonically increasing `completed_at` so the
  27. scheduler's `ORDER BY completed_at DESC` reliably picks the latest as
  28. the predecessor. `printer_id` is shared so all items count.
  29. """
  30. base_time = datetime(2026, 6, 6, 12, 0, 0, tzinfo=timezone.utc)
  31. counter = {"n": 0}
  32. printer_holder: dict = {}
  33. async def _make_printer():
  34. if "p" not in printer_holder:
  35. printer_holder["p"] = await printer_factory()
  36. return printer_holder["p"]
  37. async def _add(status: str, error_message: str | None = None) -> PrintQueueItem:
  38. printer = await _make_printer()
  39. counter["n"] += 1
  40. item = PrintQueueItem(
  41. printer_id=printer.id,
  42. status=status,
  43. error_message=error_message,
  44. completed_at=base_time + timedelta(minutes=counter["n"]),
  45. require_previous_success=True,
  46. )
  47. db_session.add(item)
  48. await db_session.commit()
  49. await db_session.refresh(item)
  50. return item
  51. async def _add_pending() -> PrintQueueItem:
  52. printer = await _make_printer()
  53. item = PrintQueueItem(
  54. printer_id=printer.id,
  55. status="pending",
  56. require_previous_success=True,
  57. )
  58. db_session.add(item)
  59. await db_session.commit()
  60. await db_session.refresh(item)
  61. return item
  62. return {"add": _add, "add_pending": _add_pending}
  63. @pytest.mark.asyncio
  64. async def test_no_previous_item_returns_true(scheduler, db_session, queue_factory):
  65. """First item in the queue has no predecessor → always passes."""
  66. pending = await queue_factory["add_pending"]()
  67. assert await scheduler._check_previous_success(db_session, pending) is True
  68. @pytest.mark.asyncio
  69. async def test_previous_completed_returns_true(scheduler, db_session, queue_factory):
  70. await queue_factory["add"]("completed")
  71. pending = await queue_factory["add_pending"]()
  72. assert await scheduler._check_previous_success(db_session, pending) is True
  73. @pytest.mark.asyncio
  74. async def test_previous_failed_returns_false(scheduler, db_session, queue_factory):
  75. await queue_factory["add"]("failed")
  76. pending = await queue_factory["add_pending"]()
  77. assert await scheduler._check_previous_success(db_session, pending) is False
  78. @pytest.mark.asyncio
  79. async def test_previous_aborted_returns_false(scheduler, db_session, queue_factory):
  80. """A printer-detected abort (e.g. clogged nozzle) is a real failure → blocks."""
  81. await queue_factory["add"]("aborted")
  82. pending = await queue_factory["add_pending"]()
  83. assert await scheduler._check_previous_success(db_session, pending) is False
  84. @pytest.mark.asyncio
  85. async def test_previous_cancelled_returns_true_bug_a(scheduler, db_session, queue_factory):
  86. """#1667 bug A: user cancellation is deliberate, not a failure → passes."""
  87. await queue_factory["add"]("cancelled")
  88. pending = await queue_factory["add_pending"]()
  89. assert await scheduler._check_previous_success(db_session, pending) is True
  90. @pytest.mark.asyncio
  91. async def test_skipped_predecessor_is_walked_past_bug_b(scheduler, db_session, queue_factory):
  92. """#1667 bug B: a skipped item is not an attempt — query walks back to the
  93. most recent real outcome instead of treating skipped as failed."""
  94. await queue_factory["add"]("completed") # real predecessor that should be found
  95. await queue_factory["add"]("skipped", "Previous print failed or was aborted")
  96. pending = await queue_factory["add_pending"]()
  97. assert await scheduler._check_previous_success(db_session, pending) is True
  98. @pytest.mark.asyncio
  99. async def test_only_skipped_history_returns_true(scheduler, db_session, queue_factory):
  100. """Edge case: every prior item is skipped → no real predecessor found,
  101. returns True (first-in-queue semantics)."""
  102. await queue_factory["add"]("skipped", "Previous print failed or was aborted")
  103. await queue_factory["add"]("skipped", "Previous print failed or was aborted")
  104. pending = await queue_factory["add_pending"]()
  105. assert await scheduler._check_previous_success(db_session, pending) is True
  106. @pytest.mark.asyncio
  107. async def test_cascade_reporters_scenario(scheduler, db_session, queue_factory):
  108. """The exact #1667 reporter scenario: failed → cancelled → skipped → pending.
  109. Pre-fix: pending blocked because the buggy lookback walked past the
  110. cancelled item (excluded) and the prior skipped item (included), found
  111. the failed item, and returned False.
  112. Post-fix: cancelled is the predecessor (skipped is excluded; cancelled
  113. is included and passes), pending dispatches.
  114. """
  115. await queue_factory["add"]("failed")
  116. await queue_factory["add"]("cancelled")
  117. await queue_factory["add"]("skipped", "Previous print failed or was aborted")
  118. pending = await queue_factory["add_pending"]()
  119. assert await scheduler._check_previous_success(db_session, pending) is True
  120. @pytest.mark.asyncio
  121. async def test_failed_then_cancelled_still_passes(scheduler, db_session, queue_factory):
  122. """User cancelled after a failure → most recent action wins. The cancellation
  123. is the user explicitly choosing to move on, so dispatching the next item
  124. respects their intent."""
  125. await queue_factory["add"]("failed")
  126. await queue_factory["add"]("cancelled")
  127. pending = await queue_factory["add_pending"]()
  128. assert await scheduler._check_previous_success(db_session, pending) is True
  129. @pytest.mark.asyncio
  130. async def test_completed_then_failed_blocks(scheduler, db_session, queue_factory):
  131. """Regression guard: a real failure after a previously-successful print
  132. still gates downstream items. Only the MOST RECENT outcome matters."""
  133. await queue_factory["add"]("completed")
  134. await queue_factory["add"]("failed")
  135. pending = await queue_factory["add_pending"]()
  136. assert await scheduler._check_previous_success(db_session, pending) is False