test_queue_variants_api.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  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_rejects_the_same_file_twice(self, async_client, sliced_file_factory, printer_factory):
  138. await printer_factory(model="H2S")
  139. f = await sliced_file_factory("H2S")
  140. r = await _queue_variants(async_client, f.id, f.id)
  141. assert r.status_code == 400
  142. @pytest.mark.asyncio
  143. @pytest.mark.integration
  144. async def test_cross_model_gate_applies_to_every_candidate(
  145. self, async_client, sliced_file_factory, printer_factory
  146. ):
  147. """A set is only as safe as its worst member."""
  148. await printer_factory(model="H2S")
  149. await printer_factory(model="H2C")
  150. good = await sliced_file_factory("H2S")
  151. # Declares X1C but is offered as an H2C candidate.
  152. bad = await sliced_file_factory("X1C")
  153. r = await async_client.post(
  154. "/api/v1/queue/",
  155. json={
  156. "variants": [
  157. {"library_file_id": good.id},
  158. {"library_file_id": bad.id, "target_model": "H2C"},
  159. ]
  160. },
  161. )
  162. assert r.status_code == 400
  163. assert "sliced for X1C" in r.json()["detail"]
  164. @pytest.mark.asyncio
  165. @pytest.mark.integration
  166. async def test_one_candidate_without_a_printer_is_allowed(
  167. self, async_client, db_session, sliced_file_factory, printer_factory
  168. ):
  169. """Slicing for the H2C before the H2C arrives is reasonable. Refusing the
  170. whole queue action over it would be worse than that candidate simply
  171. never matching."""
  172. await printer_factory(model="H2S")
  173. h2s = await sliced_file_factory("H2S")
  174. h2c = await sliced_file_factory("H2C")
  175. r = await _queue_variants(async_client, h2s.id, h2c.id)
  176. assert r.status_code == 200
  177. assert len(await _variants_of(db_session, r.json()["id"])) == 2
  178. @pytest.mark.asyncio
  179. @pytest.mark.integration
  180. async def test_rejected_when_no_candidate_has_a_printer(self, async_client, sliced_file_factory):
  181. """Nothing in the set can ever run — that is a job that waits forever."""
  182. h2s = await sliced_file_factory("H2S")
  183. h2c = await sliced_file_factory("H2C")
  184. r = await _queue_variants(async_client, h2s.id, h2c.id)
  185. assert r.status_code == 400
  186. assert "No active printers" in r.json()["detail"]
  187. @pytest.mark.asyncio
  188. @pytest.mark.integration
  189. async def test_assigning_a_printer_is_refused(self, async_client, db_session, sliced_file_factory, printer_factory):
  190. """The edit dialog offers a printer picker for every queue item. Taking it
  191. would leave a row with variants AND a printer_id — and the fixed-printer
  192. branch of the scheduler wins that race, dispatching a row whose
  193. library_file_id is still null."""
  194. printer = await printer_factory(model="H2S")
  195. await printer_factory(model="H2C")
  196. h2s = await sliced_file_factory("H2S")
  197. h2c = await sliced_file_factory("H2C")
  198. item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
  199. r = await async_client.patch(f"/api/v1/queue/{item_id}", json={"printer_id": printer.id})
  200. assert r.status_code == 400
  201. assert "alternatives" in r.json()["detail"]
  202. assert len(await _variants_of(db_session, item_id)) == 2, "the alternatives survive the refusal"
  203. @pytest.mark.asyncio
  204. @pytest.mark.integration
  205. async def test_narrowing_to_one_model_is_refused(self, async_client, sliced_file_factory, printer_factory):
  206. """Saving "Any H2C" over a two-candidate job would silently discard the
  207. H2S alternative the user deliberately queued."""
  208. await printer_factory(model="H2S")
  209. await printer_factory(model="H2C")
  210. h2s = await sliced_file_factory("H2S")
  211. h2c = await sliced_file_factory("H2C")
  212. item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
  213. r = await async_client.patch(f"/api/v1/queue/{item_id}", json={"target_model": "H2C"})
  214. assert r.status_code == 400
  215. @pytest.mark.asyncio
  216. @pytest.mark.integration
  217. async def test_resending_the_unchanged_model_is_allowed(self, async_client, sliced_file_factory, printer_factory):
  218. """The edit dialog re-sends target_model on every save, so an unchanged
  219. value must not block editing the schedule or print options."""
  220. await printer_factory(model="H2S")
  221. await printer_factory(model="H2C")
  222. h2s = await sliced_file_factory("H2S")
  223. h2c = await sliced_file_factory("H2C")
  224. created = (await _queue_variants(async_client, h2s.id, h2c.id)).json()
  225. r = await async_client.patch(
  226. f"/api/v1/queue/{created['id']}",
  227. json={"target_model": created["target_model"], "timelapse": True},
  228. )
  229. assert r.status_code == 200
  230. assert r.json()["timelapse"] is True
  231. assert len(r.json()["variants"]) == 2, "the response still carries the alternatives"
  232. @pytest.mark.asyncio
  233. @pytest.mark.integration
  234. async def test_quantity_gives_each_copy_its_own_candidates(
  235. self, async_client, db_session, sliced_file_factory, printer_factory
  236. ):
  237. """Attempt counts are per-item, and two copies must be free to land on
  238. different printers."""
  239. from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
  240. await printer_factory(model="H2S")
  241. await printer_factory(model="H2C")
  242. h2s = await sliced_file_factory("H2S")
  243. h2c = await sliced_file_factory("H2C")
  244. r = await _queue_variants(async_client, h2s.id, h2c.id, quantity=3)
  245. assert r.status_code == 200
  246. item_ids = (await db_session.execute(select(PrintQueueItem.id))).scalars().all()
  247. assert len(item_ids) == 3
  248. total = (await db_session.execute(select(PrintQueueVariant))).scalars().all()
  249. assert len(total) == 6