test_queue_variants_api.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. """Queueing a job with cross-model alternatives (#671).
  2. One queue item, several sliced files, whichever printer frees up first. The
  3. create endpoint's job is to refuse candidate sets that cannot mean what the user
  4. intends, because after this point the scheduler dispatches to hardware with no
  5. human in the loop.
  6. """
  7. import pytest
  8. from httpx import AsyncClient
  9. from sqlalchemy import select
  10. @pytest.fixture
  11. async def sliced_file_factory(db_session):
  12. _counter = [0]
  13. async def _create(model: str | None = "H2S", **kwargs):
  14. from backend.app.models.library import LibraryFile
  15. _counter[0] += 1
  16. defaults = {
  17. "filename": f"job_{_counter[0]}.gcode.3mf",
  18. "file_path": f"/test/job_{_counter[0]}.gcode.3mf",
  19. "file_size": 100,
  20. "file_type": "gcode.3mf",
  21. "file_metadata": {"sliced_for_model": model} if model else {},
  22. }
  23. defaults.update(kwargs)
  24. f = LibraryFile(**defaults)
  25. db_session.add(f)
  26. await db_session.commit()
  27. await db_session.refresh(f)
  28. return f
  29. return _create
  30. async def _queue_variants(client: AsyncClient, *file_ids: int, **extra):
  31. payload = {"variants": [{"library_file_id": fid} for fid in file_ids]}
  32. payload.update(extra)
  33. return await client.post("/api/v1/queue/", json=payload)
  34. async def _variants_of(db_session, item_id: int):
  35. from backend.app.models.print_queue import PrintQueueVariant
  36. rows = (
  37. (
  38. await db_session.execute(
  39. select(PrintQueueVariant)
  40. .where(PrintQueueVariant.queue_item_id == item_id)
  41. .order_by(PrintQueueVariant.position)
  42. )
  43. )
  44. .scalars()
  45. .all()
  46. )
  47. return rows
  48. class TestQueueWithVariants:
  49. @pytest.mark.asyncio
  50. @pytest.mark.integration
  51. async def test_creates_one_item_with_a_candidate_per_file(
  52. self, async_client, db_session, sliced_file_factory, printer_factory
  53. ):
  54. await printer_factory(model="H2S")
  55. await printer_factory(model="H2C")
  56. h2s = await sliced_file_factory("H2S")
  57. h2c = await sliced_file_factory("H2C")
  58. r = await _queue_variants(async_client, h2s.id, h2c.id)
  59. assert r.status_code == 200
  60. item_id = r.json()["id"]
  61. variants = await _variants_of(db_session, item_id)
  62. assert [v.target_model for v in variants] == ["H2S", "H2C"]
  63. assert [v.position for v in variants] == [0, 1]
  64. @pytest.mark.asyncio
  65. @pytest.mark.integration
  66. async def test_the_item_holds_no_file_of_its_own(
  67. self, async_client, db_session, sliced_file_factory, printer_factory
  68. ):
  69. """library_file_id is ON DELETE CASCADE. Pointing it at one candidate
  70. would mean deleting that single alternative destroys the whole job."""
  71. from backend.app.models.print_queue import PrintQueueItem
  72. await printer_factory(model="H2S")
  73. await printer_factory(model="H2C")
  74. h2s = await sliced_file_factory("H2S")
  75. h2c = await sliced_file_factory("H2C")
  76. item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
  77. item = (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  78. assert item.library_file_id is None
  79. assert item.archive_id is None
  80. assert item.target_model == "H2S", "mirrors the first candidate so the card has a label"
  81. @pytest.mark.asyncio
  82. @pytest.mark.integration
  83. async def test_deleting_one_candidate_leaves_the_job_and_its_sibling(
  84. self, async_client, db_session, sliced_file_factory, printer_factory
  85. ):
  86. from backend.app.models.print_queue import PrintQueueItem
  87. await printer_factory(model="H2S")
  88. await printer_factory(model="H2C")
  89. h2s = await sliced_file_factory("H2S")
  90. h2c = await sliced_file_factory("H2C")
  91. item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
  92. # Trash, then permanently delete — the only path that actually removes
  93. # the row. SQLite has PRAGMA foreign_keys off, so nothing cleans the
  94. # candidate up on its own.
  95. assert (await async_client.delete(f"/api/v1/library/files/{h2s.id}")).status_code == 200
  96. assert (await async_client.delete(f"/api/v1/library/trash/{h2s.id}")).status_code == 200
  97. item = (
  98. await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
  99. ).scalar_one_or_none()
  100. assert item is not None, "the job survives losing one alternative"
  101. remaining = await _variants_of(db_session, item_id)
  102. assert [v.target_model for v in remaining] == ["H2C"], "no row left pointing at a deleted file"
  103. @pytest.mark.asyncio
  104. @pytest.mark.integration
  105. async def test_rejects_a_specific_printer(self, async_client, sliced_file_factory, printer_factory):
  106. """Naming a printer defeats the entire purpose of offering alternatives."""
  107. printer = await printer_factory(model="H2S")
  108. await printer_factory(model="H2C")
  109. h2s = await sliced_file_factory("H2S")
  110. h2c = await sliced_file_factory("H2C")
  111. r = await _queue_variants(async_client, h2s.id, h2c.id, printer_id=printer.id)
  112. assert r.status_code == 400
  113. assert "printer_id" in r.json()["detail"]
  114. @pytest.mark.asyncio
  115. @pytest.mark.integration
  116. async def test_rejects_a_file_alongside_the_variants(self, async_client, sliced_file_factory, printer_factory):
  117. await printer_factory(model="H2S")
  118. await printer_factory(model="H2C")
  119. h2s = await sliced_file_factory("H2S")
  120. h2c = await sliced_file_factory("H2C")
  121. other = await sliced_file_factory("H2D")
  122. r = await _queue_variants(async_client, h2s.id, h2c.id, library_file_id=other.id)
  123. assert r.status_code == 400
  124. @pytest.mark.asyncio
  125. @pytest.mark.integration
  126. async def test_rejects_two_candidates_for_the_same_printer(
  127. self, async_client, sliced_file_factory, printer_factory
  128. ):
  129. await printer_factory(model="H2S")
  130. a = await sliced_file_factory("H2S")
  131. b = await sliced_file_factory("H2S")
  132. r = await _queue_variants(async_client, a.id, b.id)
  133. assert r.status_code == 400
  134. assert "different printers" in r.json()["detail"]
  135. @pytest.mark.asyncio
  136. @pytest.mark.integration
  137. async def test_the_same_model_in_another_case_is_the_same_printers(
  138. self, async_client, db_session, sliced_file_factory, printer_factory
  139. ):
  140. """Matched without regard to case, as the scheduler matches (Q-U8)."""
  141. await printer_factory(model="H2S")
  142. a = await sliced_file_factory("H2S")
  143. b = await sliced_file_factory("h2s")
  144. r = await _queue_variants(async_client, a.id, b.id)
  145. assert r.status_code == 400
  146. assert "different printers" in r.json()["detail"]
  147. @pytest.mark.asyncio
  148. @pytest.mark.integration
  149. async def test_a_candidate_takes_the_printers_spelling(
  150. self, async_client, db_session, sliced_file_factory, printer_factory
  151. ):
  152. await printer_factory(model="H2S")
  153. await printer_factory(model="X1C")
  154. a = await sliced_file_factory("h2s")
  155. b = await sliced_file_factory("X1C")
  156. r = await _queue_variants(async_client, a.id, b.id)
  157. assert r.status_code == 200, r.text
  158. assert [v.target_model for v in await _variants_of(db_session, r.json()["id"])] == ["H2S", "X1C"]
  159. @pytest.mark.asyncio
  160. @pytest.mark.integration
  161. async def test_rejects_the_same_file_twice(self, async_client, sliced_file_factory, printer_factory):
  162. await printer_factory(model="H2S")
  163. f = await sliced_file_factory("H2S")
  164. r = await _queue_variants(async_client, f.id, f.id)
  165. assert r.status_code == 400
  166. @pytest.mark.asyncio
  167. @pytest.mark.integration
  168. async def test_cross_model_gate_applies_to_every_candidate(
  169. self, async_client, sliced_file_factory, printer_factory
  170. ):
  171. """A set is only as safe as its worst member."""
  172. await printer_factory(model="H2S")
  173. await printer_factory(model="H2C")
  174. good = await sliced_file_factory("H2S")
  175. # Declares X1C but is offered as an H2C candidate.
  176. bad = await sliced_file_factory("X1C")
  177. r = await async_client.post(
  178. "/api/v1/queue/",
  179. json={
  180. "variants": [
  181. {"library_file_id": good.id},
  182. {"library_file_id": bad.id, "target_model": "H2C"},
  183. ]
  184. },
  185. )
  186. assert r.status_code == 400
  187. assert "sliced for X1C" in r.json()["detail"]
  188. @pytest.mark.asyncio
  189. @pytest.mark.integration
  190. async def test_one_candidate_without_a_printer_is_allowed(
  191. self, async_client, db_session, sliced_file_factory, printer_factory
  192. ):
  193. """Slicing for the H2C before the H2C arrives is reasonable. Refusing the
  194. whole queue action over it would be worse than that candidate simply
  195. never matching."""
  196. await printer_factory(model="H2S")
  197. h2s = await sliced_file_factory("H2S")
  198. h2c = await sliced_file_factory("H2C")
  199. r = await _queue_variants(async_client, h2s.id, h2c.id)
  200. assert r.status_code == 200
  201. assert len(await _variants_of(db_session, r.json()["id"])) == 2
  202. @pytest.mark.asyncio
  203. @pytest.mark.integration
  204. async def test_rejected_when_no_candidate_has_a_printer(self, async_client, sliced_file_factory):
  205. """Nothing in the set can ever run — that is a job that waits forever."""
  206. h2s = await sliced_file_factory("H2S")
  207. h2c = await sliced_file_factory("H2C")
  208. r = await _queue_variants(async_client, h2s.id, h2c.id)
  209. assert r.status_code == 400
  210. assert "No active printers" in r.json()["detail"]
  211. @pytest.mark.asyncio
  212. @pytest.mark.integration
  213. async def test_assigning_a_printer_is_refused(self, async_client, db_session, sliced_file_factory, printer_factory):
  214. """The edit dialog offers a printer picker for every queue item. Taking it
  215. would leave a row with variants AND a printer_id — and the fixed-printer
  216. branch of the scheduler wins that race, dispatching a row whose
  217. library_file_id is still null."""
  218. printer = await printer_factory(model="H2S")
  219. await printer_factory(model="H2C")
  220. h2s = await sliced_file_factory("H2S")
  221. h2c = await sliced_file_factory("H2C")
  222. item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
  223. r = await async_client.patch(f"/api/v1/queue/{item_id}", json={"printer_id": printer.id})
  224. assert r.status_code == 400
  225. assert "alternatives" in r.json()["detail"]
  226. assert len(await _variants_of(db_session, item_id)) == 2, "the alternatives survive the refusal"
  227. @pytest.mark.asyncio
  228. @pytest.mark.integration
  229. async def test_narrowing_to_one_model_is_refused(self, async_client, sliced_file_factory, printer_factory):
  230. """Saving "Any H2C" over a two-candidate job would silently discard the
  231. H2S alternative the user deliberately queued."""
  232. await printer_factory(model="H2S")
  233. await printer_factory(model="H2C")
  234. h2s = await sliced_file_factory("H2S")
  235. h2c = await sliced_file_factory("H2C")
  236. item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
  237. r = await async_client.patch(f"/api/v1/queue/{item_id}", json={"target_model": "H2C"})
  238. assert r.status_code == 400
  239. @pytest.mark.asyncio
  240. @pytest.mark.integration
  241. async def test_resending_the_unchanged_model_is_allowed(self, async_client, sliced_file_factory, printer_factory):
  242. """The edit dialog re-sends target_model on every save, so an unchanged
  243. value must not block editing the schedule or print options."""
  244. await printer_factory(model="H2S")
  245. await printer_factory(model="H2C")
  246. h2s = await sliced_file_factory("H2S")
  247. h2c = await sliced_file_factory("H2C")
  248. created = (await _queue_variants(async_client, h2s.id, h2c.id)).json()
  249. r = await async_client.patch(
  250. f"/api/v1/queue/{created['id']}",
  251. json={"target_model": created["target_model"], "timelapse": True},
  252. )
  253. assert r.status_code == 200
  254. assert r.json()["timelapse"] is True
  255. assert len(r.json()["variants"]) == 2, "the response still carries the alternatives"
  256. @pytest.mark.asyncio
  257. @pytest.mark.integration
  258. async def test_quantity_gives_each_copy_its_own_candidates(
  259. self, async_client, db_session, sliced_file_factory, printer_factory
  260. ):
  261. """Attempt counts are per-item, and two copies must be free to land on
  262. different printers."""
  263. from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
  264. await printer_factory(model="H2S")
  265. await printer_factory(model="H2C")
  266. h2s = await sliced_file_factory("H2S")
  267. h2c = await sliced_file_factory("H2C")
  268. r = await _queue_variants(async_client, h2s.id, h2c.id, quantity=3)
  269. assert r.status_code == 200
  270. item_ids = (await db_session.execute(select(PrintQueueItem.id))).scalars().all()
  271. assert len(item_ids) == 3
  272. total = (await db_session.execute(select(PrintQueueVariant))).scalars().all()
  273. assert len(total) == 6
  274. @pytest.mark.asyncio
  275. @pytest.mark.integration
  276. async def test_quantity_batch_is_named_after_the_first_candidate(
  277. self, async_client, db_session, sliced_file_factory, printer_factory
  278. ):
  279. """A cross-model job has no archive_id and no library_file_id -- the
  280. candidates are the files -- so the batch name has to come from one of
  281. them or every such order reads "Batch" in the Batches tab (#3101)."""
  282. from backend.app.models.print_batch import PrintBatch
  283. await printer_factory(model="H2S")
  284. await printer_factory(model="H2C")
  285. h2s = await sliced_file_factory("H2S", filename="bloom.gcode.3mf")
  286. h2c = await sliced_file_factory("H2C")
  287. r = await _queue_variants(async_client, h2s.id, h2c.id, quantity=4)
  288. assert r.status_code == 200
  289. batch = (await db_session.execute(select(PrintBatch))).scalars().one()
  290. assert batch.name == "bloom ×4"
  291. # Both stay null: the row cannot name one source without disowning the
  292. # others, and every consumer derives progress from the items instead.
  293. assert batch.archive_id is None
  294. assert batch.library_file_id is None