test_spool_archive_unassigns.py 15 KB

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