test_spool_archive_unassigns.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. """An archived or deleted spool gives its AMS slots up.
  2. Assigning refuses an archived spool, and nothing else looks at the archive
  3. flag: a slot left pointing at one kept showing it, and the next print charged
  4. its usage to it. A deleted Spoolman spool left its slot row pointing at an id
  5. Spoolman no longer has.
  6. """
  7. from unittest.mock import AsyncMock, MagicMock, patch
  8. import pytest
  9. from httpx import AsyncClient
  10. from sqlalchemy import select
  11. from backend.app.models.spool import Spool
  12. from backend.app.models.spool_assignment import SpoolAssignment
  13. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  14. SPOOLMAN_SPOOL = {
  15. "id": 42,
  16. "filament": {"id": 1, "name": "PLA", "material": "PLA", "weight": 1000, "color_hex": "FF0000"},
  17. "remaining_weight": 800.0,
  18. "used_weight": 200.0,
  19. "archived": False,
  20. "extra": {},
  21. }
  22. async def _assigned_built_in_spools(db_session, printer_factory, count=1) -> list[int]:
  23. printer = await printer_factory()
  24. ids = []
  25. for tray in range(count):
  26. spool = Spool(material="PLA", label_weight=1000, weight_used=0)
  27. db_session.add(spool)
  28. await db_session.flush()
  29. db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=tray))
  30. ids.append(spool.id)
  31. await db_session.commit()
  32. return ids
  33. async def _built_in_slots(db_session) -> list:
  34. return list((await db_session.execute(select(SpoolAssignment))).scalars().all())
  35. @pytest.mark.asyncio
  36. @pytest.mark.integration
  37. async def test_archiving_a_spool_unassigns_it(async_client: AsyncClient, db_session, printer_factory):
  38. (spool_id,) = await _assigned_built_in_spools(db_session, printer_factory)
  39. with patch("backend.app.api.routes.inventory.ws_manager") as ws:
  40. ws.broadcast = AsyncMock()
  41. response = await async_client.post(f"/api/v1/inventory/spools/{spool_id}/archive")
  42. assert response.status_code == 200
  43. assert await _built_in_slots(db_session) == []
  44. sent = [c.args[0]["type"] for c in ws.broadcast.call_args_list]
  45. assert "spool_assignment_changed" in sent
  46. @pytest.mark.asyncio
  47. @pytest.mark.integration
  48. async def test_bulk_archiving_unassigns_every_spool(async_client: AsyncClient, db_session, printer_factory):
  49. ids = await _assigned_built_in_spools(db_session, printer_factory, count=2)
  50. response = await async_client.post("/api/v1/inventory/spools/bulk-archive", json={"ids": ids})
  51. assert response.status_code == 200
  52. assert await _built_in_slots(db_session) == []
  53. @pytest.fixture
  54. async def spoolman_slot(db_session, printer_factory):
  55. from backend.app.models.settings import Settings
  56. db_session.add(Settings(key="spoolman_enabled", value="true"))
  57. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  58. printer = await printer_factory()
  59. db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=42))
  60. await db_session.commit()
  61. def _client():
  62. client = MagicMock()
  63. client.base_url = "http://localhost:7912"
  64. client.health_check = AsyncMock(return_value=True)
  65. client.delete_spool = AsyncMock(return_value=True)
  66. client.set_spool_archived = AsyncMock(
  67. side_effect=lambda spool_id, archived: {**SPOOLMAN_SPOOL, "archived": archived}
  68. )
  69. return client
  70. @pytest.mark.asyncio
  71. @pytest.mark.integration
  72. @pytest.mark.parametrize(
  73. "method,path,body",
  74. [
  75. ("post", "/api/v1/spoolman/inventory/spools/42/archive", None),
  76. ("post", "/api/v1/spoolman/inventory/spools/bulk-archive", {"ids": [42]}),
  77. ("delete", "/api/v1/spoolman/inventory/spools/42", None),
  78. ("post", "/api/v1/spoolman/inventory/spools/bulk-delete", {"ids": [42]}),
  79. ],
  80. )
  81. async def test_a_spoolman_spool_archived_or_deleted_gives_its_slot_up(
  82. async_client: AsyncClient, db_session, spoolman_slot, method, path, body
  83. ):
  84. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=_client())):
  85. call = getattr(async_client, method)
  86. response = await (call(path, json=body) if body is not None else call(path))
  87. assert response.status_code == 200, response.text
  88. rows = (await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all()
  89. assert rows == []
  90. @pytest.mark.asyncio
  91. @pytest.mark.integration
  92. async def test_an_archived_spools_tag_still_in_the_ams_is_not_adopted(test_engine, db_session, printer_factory):
  93. """The spool ran out and was archived; its empty core, tag intact, waits in
  94. the AMS to be swapped. The freed slot stays free: no new spool is made
  95. from the tag, and no unopened spool of the same filament takes it over."""
  96. from datetime import datetime, timezone
  97. from types import SimpleNamespace
  98. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  99. from backend.app.main import on_ams_change
  100. printer = await printer_factory()
  101. uuid = "A1B2C3D4E5F60718293A4B5C6D7E8F90"
  102. db_session.add(
  103. Spool(
  104. material="PLA",
  105. label_weight=1000,
  106. weight_used=1000,
  107. tray_uuid=uuid,
  108. tag_uid="0A1B2C3D4E5F6071",
  109. archived_at=datetime.now(timezone.utc),
  110. )
  111. )
  112. # An unopened spool of the same filament, which an unknown tag would be linked to.
  113. db_session.add(Spool(material="PLA", label_weight=1000, weight_used=0, rgba="FF0000FF"))
  114. await db_session.commit()
  115. ams_data = [
  116. {
  117. "id": 0,
  118. "tray": [
  119. {
  120. "id": 0,
  121. "tray_type": "PLA",
  122. "tray_color": "FF0000FF",
  123. "tray_info_idx": "GFA00",
  124. "tray_uuid": uuid,
  125. "tag_uid": "0A1B2C3D4E5F6071",
  126. "remain": 0,
  127. }
  128. ],
  129. }
  130. ]
  131. state = SimpleNamespace(raw_data={"ams": {"ams": ams_data}}, state="IDLE", connected=True)
  132. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  133. with (
  134. patch("backend.app.main.async_session", real_db),
  135. patch("backend.app.core.database.async_session", real_db),
  136. patch("backend.app.main.printer_manager") as pm,
  137. patch("backend.app.main.mqtt_relay") as relay,
  138. patch("backend.app.main.ws_manager") as ws,
  139. ):
  140. pm.get_printer.return_value = MagicMock(name="X1C", serial_number="ARCHTAG")
  141. pm.get_status.return_value = state
  142. pm.get_client.return_value = MagicMock()
  143. pm.get_model.return_value = "X1C"
  144. relay.on_ams_change = AsyncMock()
  145. ws.send_printer_status = AsyncMock()
  146. ws.broadcast = AsyncMock()
  147. await on_ams_change(printer.id, ams_data)
  148. db_session.expire_all()
  149. assert await _built_in_slots(db_session) == []
  150. spools = (await db_session.execute(select(Spool))).scalars().all()
  151. assert len(spools) == 2
  152. unopened = next(s for s in spools if s.archived_at is None)
  153. assert unopened.tray_uuid is None and unopened.tag_uid is None
  154. @pytest.mark.asyncio
  155. @pytest.mark.integration
  156. async def test_archiving_a_spoolman_spool_announces_the_freed_slot(
  157. async_client: AsyncClient, db_session, spoolman_slot
  158. ):
  159. with (
  160. patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=_client())),
  161. patch("backend.app.api.routes.spoolman_inventory.ws_manager") as ws,
  162. ):
  163. ws.broadcast = AsyncMock()
  164. response = await async_client.post("/api/v1/spoolman/inventory/spools/42/archive")
  165. assert response.status_code == 200
  166. sent = [c.args[0] for c in ws.broadcast.call_args_list]
  167. assert any(m["type"] == "spool_assignment_changed" and m["tray_id"] == 0 for m in sent)
  168. @pytest.mark.asyncio
  169. @pytest.mark.integration
  170. async def test_a_spoolman_spool_feeding_a_print_keeps_its_slot_until_the_print_is_charged(
  171. async_client: AsyncClient, test_engine, db_session, spoolman_slot, archive_factory
  172. ):
  173. """Its usage is charged at completion by the slot's spool, which only the
  174. slot row knows for a spool without a tag."""
  175. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  176. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  177. from backend.app.services.spoolman_tracking import release_archived_slot_rows
  178. printer_id = await db_session.scalar(select(SpoolmanSlotAssignment.printer_id))
  179. archive = await archive_factory(printer_id, status="printing", with_run=False)
  180. db_session.add(ActivePrintSpoolman(printer_id=printer_id, archive_id=archive.id, filament_usage=[], ams_trays={}))
  181. await db_session.commit()
  182. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=_client())):
  183. response = await async_client.post("/api/v1/spoolman/inventory/spools/42/archive")
  184. assert response.status_code == 200
  185. assert len((await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all()) == 1
  186. # The print completed and its usage was reported; Spoolman has the spool archived.
  187. await db_session.execute(ActivePrintSpoolman.__table__.delete())
  188. await db_session.commit()
  189. client = _client()
  190. client.get_all_spools = AsyncMock(return_value=[{**SPOOLMAN_SPOOL, "archived": True}])
  191. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  192. with (
  193. patch("backend.app.services.spoolman_tracking.async_session", real_db),
  194. patch(
  195. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", AsyncMock(return_value=client)
  196. ),
  197. patch("backend.app.core.websocket.ws_manager.broadcast", AsyncMock()),
  198. ):
  199. await release_archived_slot_rows(printer_id)
  200. db_session.expire_all()
  201. assert (await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all() == []
  202. @pytest.mark.asyncio
  203. @pytest.mark.integration
  204. async def test_the_release_after_a_print_keeps_a_live_spools_slot(test_engine, db_session, spoolman_slot):
  205. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  206. from backend.app.services.spoolman_tracking import release_archived_slot_rows
  207. printer_id = await db_session.scalar(select(SpoolmanSlotAssignment.printer_id))
  208. client = _client()
  209. client.get_all_spools = AsyncMock(return_value=[SPOOLMAN_SPOOL])
  210. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  211. with (
  212. patch("backend.app.services.spoolman_tracking.async_session", real_db),
  213. patch(
  214. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", AsyncMock(return_value=client)
  215. ),
  216. ):
  217. await release_archived_slot_rows(printer_id)
  218. db_session.expire_all()
  219. assert len((await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all()) == 1
  220. @pytest.mark.asyncio
  221. @pytest.mark.integration
  222. async def test_bulk_archive_releases_a_spool_archived_before_it_released_slots(
  223. async_client: AsyncClient, db_session, printer_factory
  224. ):
  225. from datetime import datetime, timezone
  226. (spool_id,) = await _assigned_built_in_spools(db_session, printer_factory)
  227. spool = await db_session.get(Spool, spool_id)
  228. spool.archived_at = datetime.now(timezone.utc)
  229. await db_session.commit()
  230. response = await async_client.post("/api/v1/inventory/spools/bulk-archive", json={"ids": [spool_id]})
  231. assert response.json()["already_archived"] == [spool_id]
  232. assert await _built_in_slots(db_session) == []
  233. @pytest.mark.asyncio
  234. @pytest.mark.integration
  235. async def test_the_release_after_a_print_frees_a_slot_of_a_spool_spoolman_no_longer_has(
  236. test_engine, db_session, spoolman_slot
  237. ):
  238. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  239. from backend.app.services.spoolman_tracking import release_archived_slot_rows
  240. printer_id = await db_session.scalar(select(SpoolmanSlotAssignment.printer_id))
  241. client = _client()
  242. client.get_all_spools = AsyncMock(return_value=[])
  243. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  244. with (
  245. patch("backend.app.services.spoolman_tracking.async_session", real_db),
  246. patch(
  247. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", AsyncMock(return_value=client)
  248. ),
  249. patch("backend.app.core.websocket.ws_manager.broadcast", AsyncMock()),
  250. ):
  251. await release_archived_slot_rows(printer_id)
  252. db_session.expire_all()
  253. assert (await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all() == []
  254. @pytest.mark.asyncio
  255. @pytest.mark.integration
  256. async def test_an_archived_spools_third_party_tag_still_in_the_ams_is_not_offered_as_new(
  257. test_engine, db_session, printer_factory
  258. ):
  259. from datetime import datetime, timezone
  260. from types import SimpleNamespace
  261. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  262. from backend.app.main import on_ams_change
  263. printer = await printer_factory()
  264. db_session.add(
  265. Spool(material="PETG", label_weight=1000, tag_uid="04A1B2C3D4E5F6", archived_at=datetime.now(timezone.utc))
  266. )
  267. await db_session.commit()
  268. ams_data = [
  269. {"id": 0, "tray": [{"id": 0, "tray_type": "PETG", "tray_color": "00FF00FF", "tag_uid": "04A1B2C3D4E5F6"}]}
  270. ]
  271. state = SimpleNamespace(raw_data={"ams": {"ams": ams_data}}, state="IDLE", connected=True)
  272. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  273. with (
  274. patch("backend.app.main.async_session", real_db),
  275. patch("backend.app.core.database.async_session", real_db),
  276. patch("backend.app.main.printer_manager") as pm,
  277. patch("backend.app.main.mqtt_relay") as relay,
  278. patch("backend.app.main.ws_manager") as ws,
  279. patch("backend.app.main._broadcast_unknown_tag", new_callable=AsyncMock) as prompt,
  280. ):
  281. pm.get_printer.return_value = MagicMock(name="X1C", serial_number="ARCHTAG2")
  282. pm.get_status.return_value = state
  283. pm.get_client.return_value = MagicMock()
  284. pm.get_model.return_value = "X1C"
  285. relay.on_ams_change = AsyncMock()
  286. ws.send_printer_status = AsyncMock()
  287. ws.broadcast = AsyncMock()
  288. await on_ams_change(printer.id, ams_data)
  289. prompt.assert_not_awaited()