test_scheduler_pinned_waiting_reason_3074.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. """A queue item pinned to one printer explains itself (#3074).
  2. Bambuddy has two ways to queue a job. "Any X1C" goes through the model-based
  3. branch, which builds a sentence for every way the job could not start and puts
  4. it on the row: `Busy: X1C-01`, `Waiting for filament: X1C-02 (needs PETG)`. The
  5. same job pinned to a specific printer went through a branch that wrote nothing.
  6. The reporter watched a pinned item sit at `pending` with `waiting_reason: null`
  7. for fourteen minutes while its printer ran a print started from the printer's
  8. own screen. Nothing in the UI or the API said why, and a queue that will not say
  9. why it is waiting is indistinguishable from a queue that has stopped working.
  10. Two rules hold everything here together:
  11. - **Every exit writes.** Not one path out of the fixed-printer branch may leave
  12. the field as it found it, or a reason from an earlier pass outlives the
  13. condition that produced it.
  14. - **Only what the user must act on makes a noise.** A printer that is merely
  15. printing resolves itself; the wording stays inside what `_is_busy_only` reads
  16. as silent. A plate nobody has confirmed does not resolve itself, so it is
  17. worded as itself and allowed through.
  18. """
  19. from contextlib import ExitStack
  20. from types import SimpleNamespace
  21. from unittest.mock import AsyncMock, MagicMock, patch
  22. import pytest
  23. from sqlalchemy import select
  24. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  25. import backend.app.models # noqa: F401 - populate Base.metadata
  26. from backend.app.core.database import Base
  27. from backend.app.models.library import LibraryFile
  28. from backend.app.models.print_queue import PrintQueueItem
  29. from backend.app.models.printer import Printer
  30. from backend.app.models.settings import Settings
  31. from backend.app.services.print_scheduler import PrintScheduler
  32. @pytest.fixture
  33. async def ctx():
  34. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  35. async with engine.begin() as conn:
  36. await conn.run_sync(Base.metadata.create_all)
  37. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  38. async with session_maker() as db:
  39. db.add(
  40. Printer(
  41. id=1,
  42. name="X1C-01",
  43. serial_number="X1C0001",
  44. ip_address="10.0.0.1",
  45. access_code="x",
  46. model="X1C",
  47. is_active=True,
  48. )
  49. )
  50. await db.commit()
  51. try:
  52. yield SimpleNamespace(session_maker=session_maker)
  53. finally:
  54. await engine.dispose()
  55. async def _add_item(ctx, *, printer_id=1, target_model=None, position=1, manual_start=False):
  56. async with ctx.session_maker() as db:
  57. lib = LibraryFile(
  58. filename="job.gcode.3mf",
  59. file_path="/library/job.gcode.3mf",
  60. file_size=10,
  61. file_type="gcode.3mf",
  62. file_metadata={"sliced_for_model": "X1C"},
  63. )
  64. db.add(lib)
  65. await db.flush()
  66. item = PrintQueueItem(
  67. status="pending",
  68. position=position,
  69. printer_id=printer_id,
  70. target_model=target_model,
  71. library_file_id=lib.id,
  72. manual_start=manual_start,
  73. )
  74. db.add(item)
  75. await db.commit()
  76. return item.id
  77. async def _set(ctx, key, value):
  78. async with ctx.session_maker() as db:
  79. db.add(Settings(key=key, value=value))
  80. await db.commit()
  81. async def _item(ctx, item_id):
  82. async with ctx.session_maker() as db:
  83. return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  84. async def _run(
  85. ctx,
  86. scheduler,
  87. *,
  88. idle=True,
  89. connected=True,
  90. awaiting_plate_clear=False,
  91. blocked=None,
  92. plugs=None,
  93. launched=None,
  94. waiting=None,
  95. ):
  96. """One check_queue pass with the printer in the state the test cares about."""
  97. launched = launched or MagicMock()
  98. patches = [
  99. patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
  100. patch("backend.app.core.database.async_session", ctx.session_maker),
  101. patch(
  102. "backend.app.services.print_scheduler.printer_manager.is_connected",
  103. MagicMock(return_value=connected),
  104. ),
  105. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  106. patch(
  107. "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
  108. MagicMock(return_value=awaiting_plate_clear),
  109. ),
  110. patch(
  111. "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
  112. AsyncMock(return_value=blocked or {}),
  113. ),
  114. patch(
  115. "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
  116. waiting or AsyncMock(),
  117. ),
  118. patch(
  119. "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
  120. AsyncMock(),
  121. ),
  122. patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=idle)),
  123. patch.object(scheduler, "_check_auto_drying", AsyncMock()),
  124. patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
  125. patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
  126. patch.object(scheduler, "_get_smart_plugs", AsyncMock(return_value=plugs or [])),
  127. patch.object(scheduler, "_launch_uploads", launched),
  128. ]
  129. with ExitStack() as stack:
  130. for p in patches:
  131. stack.enter_context(p)
  132. await scheduler.check_queue()
  133. return launched
  134. class TestThePinnedItemSaysWhyItIsWaiting:
  135. """The reporter's four cases, each of which used to produce ``None``."""
  136. @pytest.mark.asyncio
  137. async def test_a_printer_midway_through_a_print(self, ctx):
  138. """The exact fourteen minutes from the report: a print started at the
  139. printer's own screen, and a pinned item with nothing to show for it."""
  140. item_id = await _add_item(ctx)
  141. launched = await _run(ctx, PrintScheduler(), idle=False)
  142. launched.assert_not_called()
  143. assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01"
  144. @pytest.mark.asyncio
  145. async def test_a_plate_nobody_has_confirmed(self, ctx):
  146. """The other half of the report. `_is_printer_idle` returns a plain
  147. False for both this and a running print, but they are not the same
  148. thing to the person looking at the queue: this one waits on them."""
  149. await _set(ctx, "require_plate_clear", "true")
  150. item_id = await _add_item(ctx)
  151. await _run(ctx, PrintScheduler(), idle=False, awaiting_plate_clear=True)
  152. assert (await _item(ctx, item_id)).waiting_reason == "Waiting for plate confirmation: X1C-01"
  153. @pytest.mark.asyncio
  154. async def test_an_item_queued_behind_another_on_the_same_printer(self, ctx):
  155. """The commonest case of all, and one the reporter never even reached:
  156. two items pinned to one printer. The second leaves the pass at the
  157. `busy_printers` test, several checks before anything that could have
  158. described the printer."""
  159. first = await _add_item(ctx, position=1)
  160. second = await _add_item(ctx, position=2)
  161. launched = await _run(ctx, PrintScheduler(), idle=True)
  162. assert launched.call_args[0][0] == [first]
  163. assert (await _item(ctx, second)).waiting_reason == "Busy: X1C-01"
  164. @pytest.mark.asyncio
  165. async def test_a_printer_that_is_off_with_nothing_to_switch_it_on(self, ctx):
  166. """Worded exactly as the model-based branch words it (#2786). This is
  167. the one entry here the user has to act on themselves: with no enabled
  168. Auto On plug, Bambuddy will never power this printer up for the queue."""
  169. item_id = await _add_item(ctx)
  170. await _run(ctx, PrintScheduler(), connected=False)
  171. assert (await _item(ctx, item_id)).waiting_reason == "Offline, no Auto On smart plug: X1C-01"
  172. @pytest.mark.asyncio
  173. async def test_a_drying_cycle_the_user_asked_to_block_the_queue(self, ctx):
  174. await _set(ctx, "queue_drying_block", "true")
  175. item_id = await _add_item(ctx)
  176. scheduler = PrintScheduler()
  177. scheduler._drying_in_progress[1] = True
  178. launched = await _run(ctx, scheduler, idle=True)
  179. launched.assert_not_called()
  180. assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01 (drying)"
  181. class TestTheReasonNeverOutlivesTheThingItDescribes:
  182. """Every exit from the branch writes, so no pass can leave a stale reason."""
  183. @pytest.mark.asyncio
  184. async def test_it_clears_the_moment_the_item_goes_out(self, ctx):
  185. item_id = await _add_item(ctx)
  186. scheduler = PrintScheduler()
  187. await _run(ctx, scheduler, idle=False)
  188. assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01"
  189. launched = await _run(ctx, scheduler, idle=True)
  190. assert launched.call_args[0][0] == [item_id]
  191. assert (await _item(ctx, item_id)).waiting_reason is None
  192. @pytest.mark.asyncio
  193. async def test_a_busy_printer_replaces_a_lifted_interlock(self, ctx):
  194. """The guarantee the interlock used to buy by clearing the field up
  195. front, now bought by the exits all writing instead."""
  196. item_id = await _add_item(ctx)
  197. scheduler = PrintScheduler()
  198. await _run(ctx, scheduler, idle=False, blocked={1: "Enclosure Door"})
  199. assert (await _item(ctx, item_id)).waiting_reason == "Waiting on Enclosure Door"
  200. await _run(ctx, scheduler, idle=False)
  201. assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01"
  202. @pytest.mark.asyncio
  203. async def test_staging_an_item_drops_the_reason_it_was_carrying(self, ctx):
  204. """A staged item never reaches the fixed-printer branch again, so
  205. whatever it was carrying when the user staged it would otherwise stand
  206. for as long as the row lived."""
  207. item_id = await _add_item(ctx)
  208. scheduler = PrintScheduler()
  209. await _run(ctx, scheduler, idle=False)
  210. assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01"
  211. async with ctx.session_maker() as db:
  212. item = (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  213. item.manual_start = True
  214. await db.commit()
  215. await _run(ctx, scheduler, idle=False)
  216. assert (await _item(ctx, item_id)).waiting_reason is None
  217. @pytest.mark.asyncio
  218. async def test_the_reason_is_written_once_not_once_per_tick(self, ctx):
  219. """Four passes over a printer that is printing throughout must not be
  220. four writes — the scheduler runs on a timer and this row is read by the
  221. UI on a poll."""
  222. item_id = await _add_item(ctx)
  223. scheduler = PrintScheduler()
  224. for _ in range(4):
  225. await _run(ctx, scheduler, idle=False)
  226. item = await _item(ctx, item_id)
  227. assert item.waiting_reason == "Busy: X1C-01"
  228. assert item.status == "pending"
  229. class TestOnlyWhatTheUserMustActOnMakesANoise:
  230. """`_is_busy_only` decided this for the model-based branch; the reporter
  231. asked for the same restraint here, and the wording is what enforces it."""
  232. @pytest.mark.asyncio
  233. async def test_a_printer_that_is_simply_printing_stays_silent(self, ctx):
  234. await _add_item(ctx)
  235. waiting = AsyncMock()
  236. await _run(ctx, PrintScheduler(), idle=False, waiting=waiting)
  237. waiting.assert_not_called()
  238. @pytest.mark.asyncio
  239. async def test_a_drying_cycle_stays_silent(self, ctx):
  240. await _set(ctx, "queue_drying_block", "true")
  241. await _add_item(ctx)
  242. scheduler = PrintScheduler()
  243. scheduler._drying_in_progress[1] = True
  244. waiting = AsyncMock()
  245. await _run(ctx, scheduler, idle=True, waiting=waiting)
  246. waiting.assert_not_called()
  247. @pytest.mark.asyncio
  248. async def test_an_item_waiting_its_turn_stays_silent(self, ctx):
  249. await _add_item(ctx, position=1)
  250. await _add_item(ctx, position=2)
  251. waiting = AsyncMock()
  252. await _run(ctx, PrintScheduler(), idle=True, waiting=waiting)
  253. waiting.assert_not_called()
  254. @pytest.mark.asyncio
  255. async def test_an_unconfirmed_plate_is_worth_saying_once(self, ctx):
  256. await _set(ctx, "require_plate_clear", "true")
  257. await _add_item(ctx)
  258. waiting = AsyncMock()
  259. scheduler = PrintScheduler()
  260. for _ in range(3):
  261. await _run(ctx, scheduler, idle=False, awaiting_plate_clear=True, waiting=waiting)
  262. waiting.assert_called_once()
  263. assert waiting.call_args.kwargs["waiting_reason"] == "Waiting for plate confirmation: X1C-01"
  264. assert waiting.call_args.kwargs["target_model"] == "X1C"
  265. @pytest.mark.asyncio
  266. async def test_the_plate_notification_survives_the_print_that_came_before_it(self, ctx):
  267. """The sequence this branch actually produces, and the one a naive
  268. "was the field empty" transition test gets wrong.
  269. Nobody's queue goes straight from idle to an unconfirmed plate. It waits
  270. behind the print first, carrying "Busy: X1C-01" for however long that
  271. takes, and only then does the plate appear. Asking whether the item was
  272. waiting at all would call that no transition and stay silent through the
  273. single case on this list that needs a human.
  274. """
  275. await _set(ctx, "require_plate_clear", "true")
  276. item_id = await _add_item(ctx)
  277. scheduler = PrintScheduler()
  278. waiting = AsyncMock()
  279. await _run(ctx, scheduler, idle=False, waiting=waiting)
  280. assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01"
  281. waiting.assert_not_called()
  282. await _run(ctx, scheduler, idle=False, awaiting_plate_clear=True, waiting=waiting)
  283. assert (await _item(ctx, item_id)).waiting_reason == "Waiting for plate confirmation: X1C-01"
  284. waiting.assert_called_once()
  285. @pytest.mark.asyncio
  286. async def test_it_does_not_ask_twice_for_the_same_thing(self, ctx):
  287. """Busy, plate, plate, plate — one notification, not three."""
  288. await _set(ctx, "require_plate_clear", "true")
  289. await _add_item(ctx)
  290. scheduler = PrintScheduler()
  291. waiting = AsyncMock()
  292. await _run(ctx, scheduler, idle=False, waiting=waiting)
  293. for _ in range(3):
  294. await _run(ctx, scheduler, idle=False, awaiting_plate_clear=True, waiting=waiting)
  295. waiting.assert_called_once()
  296. @pytest.mark.asyncio
  297. async def test_an_interlock_has_never_notified_and_still_does_not(self, ctx):
  298. """#1148 built the sensor interlock as a hold that shows on the row, not
  299. as an alert. Routing it through the shared writer must not quietly turn
  300. every open enclosure door into a notification."""
  301. item_id = await _add_item(ctx)
  302. waiting = AsyncMock()
  303. await _run(ctx, PrintScheduler(), blocked={1: "Enclosure Door"}, waiting=waiting)
  304. assert (await _item(ctx, item_id)).waiting_reason == "Waiting on Enclosure Door"
  305. waiting.assert_not_called()
  306. @pytest.mark.asyncio
  307. async def test_a_printer_nobody_can_switch_on_is_worth_saying(self, ctx):
  308. await _add_item(ctx)
  309. waiting = AsyncMock()
  310. await _run(ctx, PrintScheduler(), connected=False, waiting=waiting)
  311. waiting.assert_called_once()
  312. @pytest.mark.asyncio
  313. async def test_a_provider_that_is_down_does_not_stop_the_queue(self, ctx):
  314. """The bug being fixed is a queue that cannot say why it is waiting. A
  315. queue that stops dispatching because a webhook timed out would be the
  316. worse one."""
  317. await _set(ctx, "require_plate_clear", "true")
  318. item_id = await _add_item(ctx)
  319. scheduler = PrintScheduler()
  320. await _run(
  321. ctx,
  322. scheduler,
  323. idle=False,
  324. awaiting_plate_clear=True,
  325. waiting=AsyncMock(side_effect=RuntimeError("provider down")),
  326. )
  327. assert (await _item(ctx, item_id)).waiting_reason == "Waiting for plate confirmation: X1C-01"
  328. launched = await _run(ctx, scheduler, idle=True)
  329. assert launched.call_args[0][0] == [item_id]
  330. class TestWhatMustNotChange:
  331. @pytest.mark.asyncio
  332. async def test_an_interlock_still_reads_as_itself(self, ctx):
  333. """#1148's wording is what the user acts on — it names the sensor."""
  334. item_id = await _add_item(ctx)
  335. launched = await _run(ctx, PrintScheduler(), blocked={1: "Enclosure Door"})
  336. launched.assert_not_called()
  337. assert (await _item(ctx, item_id)).waiting_reason == "Waiting on Enclosure Door"
  338. @pytest.mark.asyncio
  339. async def test_an_idle_printer_still_dispatches(self, ctx):
  340. item_id = await _add_item(ctx)
  341. launched = await _run(ctx, PrintScheduler(), idle=True)
  342. assert launched.call_args[0][0] == [item_id]
  343. assert (await _item(ctx, item_id)).waiting_reason is None
  344. @pytest.mark.asyncio
  345. async def test_the_plate_gate_is_not_consulted_when_it_is_switched_off(self, ctx):
  346. """`require_plate_clear` defaults to off. With the gate off, an
  347. unconfirmed plate is not what is holding the queue, and saying so would
  348. send the user to a prompt that is not there."""
  349. item_id = await _add_item(ctx)
  350. await _run(ctx, PrintScheduler(), idle=False, awaiting_plate_clear=True)
  351. assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01"
  352. class TestTheWordingStaysInsideWhatIsBusyOnlyUnderstands:
  353. """The silence is enforced by string prefixes, so pin them directly rather
  354. than only through the scheduler. A future rewording that drops the ``Busy:``
  355. prefix would turn every printing fleet into a notification source."""
  356. @pytest.mark.parametrize(
  357. "reason",
  358. [
  359. "Busy: X1C-01",
  360. "Busy: X1C-01 (drying)",
  361. ],
  362. )
  363. def test_these_are_silent(self, reason):
  364. assert PrintScheduler._is_busy_only(reason) is True
  365. @pytest.mark.parametrize(
  366. "reason",
  367. [
  368. "Waiting for plate confirmation: X1C-01",
  369. "Offline, no Auto On smart plug: X1C-01",
  370. "Offline: X1C-01 — the smart plug could not power it on",
  371. "Waiting on Enclosure Door",
  372. ],
  373. )
  374. def test_these_are_not(self, reason):
  375. assert PrintScheduler._is_busy_only(reason) is False
  376. class TestPinnedHoldReason:
  377. """The one new branch, exercised without a scheduler pass around it."""
  378. def test_a_printing_printer_is_busy(self):
  379. with patch(
  380. "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
  381. MagicMock(return_value=False),
  382. ):
  383. assert PrintScheduler._pinned_hold_reason(1, "X1C-01", True) == "Busy: X1C-01"
  384. def test_an_unconfirmed_plate_is_named(self):
  385. with patch(
  386. "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
  387. MagicMock(return_value=True),
  388. ):
  389. assert PrintScheduler._pinned_hold_reason(1, "X1C-01", True) == "Waiting for plate confirmation: X1C-01"
  390. def test_the_gate_being_off_beats_the_flag(self):
  391. """The flag is persisted, so it survives the setting being turned off."""
  392. with patch(
  393. "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
  394. MagicMock(return_value=True),
  395. ):
  396. assert PrintScheduler._pinned_hold_reason(1, "X1C-01", False) == "Busy: X1C-01"
  397. def test_no_telemetry_yet_reads_as_busy(self):
  398. """A printer that reconnected a second ago has no status, which
  399. `_is_printer_idle` refuses. It resolves itself within a tick or two, and
  400. the model-based branch has always reported it as plain busy."""
  401. with patch(
  402. "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
  403. MagicMock(return_value=False),
  404. ):
  405. assert PrintScheduler._pinned_hold_reason(9, "X1C-01", True) == "Busy: X1C-01"