test_library_variants_api.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. """Integration tests for variant groups (#671 / #2570).
  2. A variant group is the user declaring that several sliced files are the same
  3. job for different printers. The endpoints exist to enforce what that statement
  4. has to mean before the scheduler acts on it without a human in the loop.
  5. """
  6. import pytest
  7. from httpx import AsyncClient
  8. @pytest.fixture
  9. async def sliced_file_factory(db_session):
  10. """Create a sliced library file declaring the model it was sliced for."""
  11. _counter = [0]
  12. async def _create(model: str | None = "H2S", **kwargs):
  13. from backend.app.models.library import LibraryFile
  14. _counter[0] += 1
  15. defaults = {
  16. "filename": f"job_{_counter[0]}.gcode.3mf",
  17. "file_path": f"/test/job_{_counter[0]}.gcode.3mf",
  18. "file_size": 100,
  19. "file_type": "gcode.3mf",
  20. "file_metadata": {"sliced_for_model": model} if model else {},
  21. }
  22. defaults.update(kwargs)
  23. f = LibraryFile(**defaults)
  24. db_session.add(f)
  25. await db_session.commit()
  26. await db_session.refresh(f)
  27. return f
  28. return _create
  29. async def _create_group(client: AsyncClient, *file_ids: int, name: str | None = None):
  30. payload = {"members": [{"library_file_id": fid} for fid in file_ids]}
  31. if name:
  32. payload["name"] = name
  33. return await client.post("/api/v1/library/variant-groups", json=payload)
  34. class TestCreateVariantGroup:
  35. @pytest.mark.asyncio
  36. @pytest.mark.integration
  37. async def test_groups_two_slices_in_priority_order(self, async_client, sliced_file_factory):
  38. h2s = await sliced_file_factory("H2S")
  39. h2c = await sliced_file_factory("H2C")
  40. r = await _create_group(async_client, h2s.id, h2c.id, name="bracket")
  41. assert r.status_code == 201
  42. body = r.json()
  43. assert body["name"] == "bracket"
  44. assert [m["target_model"] for m in body["members"]] == ["H2S", "H2C"]
  45. assert [m["position"] for m in body["members"]] == [0, 1]
  46. @pytest.mark.asyncio
  47. @pytest.mark.integration
  48. async def test_model_is_read_from_the_file_not_the_caller(self, async_client, sliced_file_factory):
  49. """The group never carries its own model data, so it cannot disagree with
  50. the 3MFs. "Bambu Lab H2S" normalizes to the same H2S the scheduler matches."""
  51. a = await sliced_file_factory("Bambu Lab H2S")
  52. b = await sliced_file_factory("O1C") # internal code for H2C
  53. body = (await _create_group(async_client, a.id, b.id)).json()
  54. assert [m["target_model"] for m in body["members"]] == ["H2S", "H2C"]
  55. @pytest.mark.asyncio
  56. @pytest.mark.integration
  57. async def test_two_slices_for_the_same_printer_are_rejected(self, async_client, sliced_file_factory):
  58. """Not alternatives — the resolver would have no basis to prefer one, and
  59. the arbitrary pick would look like a bug the first time it chose wrong."""
  60. a = await sliced_file_factory("H2S")
  61. b = await sliced_file_factory("H2S")
  62. r = await _create_group(async_client, a.id, b.id)
  63. assert r.status_code == 400
  64. assert "different printers" in r.json()["detail"]
  65. @pytest.mark.asyncio
  66. @pytest.mark.integration
  67. async def test_normalization_catches_the_same_printer_spelled_differently(self, async_client, sliced_file_factory):
  68. a = await sliced_file_factory("H2S")
  69. b = await sliced_file_factory("Bambu Lab H2S")
  70. r = await _create_group(async_client, a.id, b.id)
  71. assert r.status_code == 400
  72. @pytest.mark.asyncio
  73. @pytest.mark.integration
  74. async def test_unsliced_file_cannot_be_a_variant(self, async_client, sliced_file_factory):
  75. """A source .3mf has no G-code — it can never be dispatched to anything."""
  76. sliced = await sliced_file_factory("H2S")
  77. source = await sliced_file_factory(None, filename="model.3mf", file_type="3mf")
  78. r = await _create_group(async_client, sliced.id, source.id)
  79. assert r.status_code == 400
  80. assert "not a sliced file" in r.json()["detail"]
  81. @pytest.mark.asyncio
  82. @pytest.mark.integration
  83. async def test_file_without_a_model_must_name_one(self, async_client, sliced_file_factory):
  84. """Legacy 3MFs declare no model. Rather than guess, make the user say."""
  85. known = await sliced_file_factory("H2S")
  86. legacy = await sliced_file_factory(None)
  87. r = await _create_group(async_client, known.id, legacy.id)
  88. assert r.status_code == 400
  89. assert "does not say which printer" in r.json()["detail"]
  90. r = await async_client.post(
  91. "/api/v1/library/variant-groups",
  92. json={
  93. "members": [
  94. {"library_file_id": known.id},
  95. {"library_file_id": legacy.id, "target_model": "H2C"},
  96. ]
  97. },
  98. )
  99. assert r.status_code == 201
  100. assert [m["target_model"] for m in r.json()["members"]] == ["H2S", "H2C"]
  101. @pytest.mark.asyncio
  102. @pytest.mark.integration
  103. async def test_a_file_belongs_to_one_group_only(self, async_client, sliced_file_factory):
  104. a = await sliced_file_factory("H2S")
  105. b = await sliced_file_factory("H2C")
  106. c = await sliced_file_factory("H2D")
  107. assert (await _create_group(async_client, a.id, b.id)).status_code == 201
  108. r = await _create_group(async_client, a.id, c.id)
  109. assert r.status_code == 409
  110. assert "already belongs" in r.json()["detail"]
  111. @pytest.mark.asyncio
  112. @pytest.mark.integration
  113. async def test_single_member_is_rejected_by_the_schema(self, async_client, sliced_file_factory):
  114. only = await sliced_file_factory("H2S")
  115. r = await _create_group(async_client, only.id)
  116. assert r.status_code == 422, "a group of one expresses no choice"
  117. class TestVariantGroupMembership:
  118. @pytest.mark.asyncio
  119. @pytest.mark.integration
  120. async def test_add_version_to_an_existing_group(self, async_client, sliced_file_factory):
  121. """The common real case: the H2S version was queued last week, the H2C
  122. version was sliced today."""
  123. a = await sliced_file_factory("H2S")
  124. b = await sliced_file_factory("H2C")
  125. c = await sliced_file_factory("H2D")
  126. gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
  127. r = await async_client.post(f"/api/v1/library/variant-groups/{gid}/members", json={"library_file_id": c.id})
  128. assert r.status_code == 200
  129. assert [m["target_model"] for m in r.json()["members"]] == ["H2S", "H2C", "H2D"]
  130. @pytest.mark.asyncio
  131. @pytest.mark.integration
  132. async def test_added_member_cannot_duplicate_a_model(self, async_client, sliced_file_factory):
  133. a = await sliced_file_factory("H2S")
  134. b = await sliced_file_factory("H2C")
  135. dupe = await sliced_file_factory("H2C")
  136. gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
  137. r = await async_client.post(f"/api/v1/library/variant-groups/{gid}/members", json={"library_file_id": dupe.id})
  138. assert r.status_code == 400
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_removing_down_to_one_dissolves_the_group(self, async_client, sliced_file_factory):
  142. """A leftover one-member group would look like a choice and behave like an
  143. ordinary job — worse than no group at all."""
  144. a = await sliced_file_factory("H2S")
  145. b = await sliced_file_factory("H2C")
  146. gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
  147. r = await async_client.delete(f"/api/v1/library/variant-groups/{gid}/members/{b.id}")
  148. assert r.status_code == 204
  149. assert (await async_client.get(f"/api/v1/library/variant-groups/{gid}")).status_code == 404
  150. # ...and the survivor is still a perfectly good file.
  151. assert (await async_client.get(f"/api/v1/library/variant-groups/by-file/{a.id}")).status_code == 404
  152. @pytest.mark.asyncio
  153. @pytest.mark.integration
  154. async def test_removing_from_a_three_member_group_keeps_it(self, async_client, sliced_file_factory):
  155. a = await sliced_file_factory("H2S")
  156. b = await sliced_file_factory("H2C")
  157. c = await sliced_file_factory("H2D")
  158. gid = (await _create_group(async_client, a.id, b.id, c.id)).json()["id"]
  159. assert (await async_client.delete(f"/api/v1/library/variant-groups/{gid}/members/{c.id}")).status_code == 204
  160. body = (await async_client.get(f"/api/v1/library/variant-groups/{gid}")).json()
  161. assert [m["library_file_id"] for m in body["members"]] == [a.id, b.id]
  162. @pytest.mark.asyncio
  163. @pytest.mark.integration
  164. async def test_deleting_a_group_keeps_the_files(self, async_client, sliced_file_factory):
  165. a = await sliced_file_factory("H2S")
  166. b = await sliced_file_factory("H2C")
  167. gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
  168. assert (await async_client.delete(f"/api/v1/library/variant-groups/{gid}")).status_code == 204
  169. for f in (a, b):
  170. assert (await async_client.get(f"/api/v1/library/files/{f.id}")).status_code == 200
  171. class TestVariantGroupOrdering:
  172. @pytest.mark.asyncio
  173. @pytest.mark.integration
  174. async def test_reorder_changes_priority(self, async_client, sliced_file_factory):
  175. """Order is the user saying which printer they would rather have when both
  176. are free, so it has to be editable."""
  177. a = await sliced_file_factory("H2S")
  178. b = await sliced_file_factory("H2C")
  179. gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
  180. r = await async_client.patch(f"/api/v1/library/variant-groups/{gid}", json={"member_file_ids": [b.id, a.id]})
  181. assert r.status_code == 200
  182. assert [m["target_model"] for m in r.json()["members"]] == ["H2C", "H2S"]
  183. @pytest.mark.asyncio
  184. @pytest.mark.integration
  185. async def test_partial_reorder_is_rejected(self, async_client, sliced_file_factory):
  186. """Listing a subset would leave the rest in an order nobody chose."""
  187. a = await sliced_file_factory("H2S")
  188. b = await sliced_file_factory("H2C")
  189. gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
  190. r = await async_client.patch(f"/api/v1/library/variant-groups/{gid}", json={"member_file_ids": [a.id]})
  191. assert r.status_code == 400
  192. @pytest.mark.asyncio
  193. @pytest.mark.integration
  194. async def test_lookup_by_file(self, async_client, sliced_file_factory):
  195. """Both consumers start from a file: the print modal knows what was
  196. clicked, the queue flow knows what was selected."""
  197. a = await sliced_file_factory("H2S")
  198. b = await sliced_file_factory("H2C")
  199. gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
  200. r = await async_client.get(f"/api/v1/library/variant-groups/by-file/{b.id}")
  201. assert r.status_code == 200
  202. assert r.json()["id"] == gid