test_spoolman_slot_announcements_3402.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. """Spoolman slot changes are announced to pages open elsewhere (#3402).
  2. A page refetches its slot rows, and the tag -> spool map the slot cards fall
  3. back on, when the server announces a change over the websocket. A scale that
  4. assigned a spool through the API left the printer page's hover card on the
  5. previous spool until a reload. The assign route announces its slots since
  6. #1606; these are the other Spoolman routes that write or clear a slot row or a
  7. spool's tag, which built-in inventory already announces.
  8. """
  9. from types import SimpleNamespace
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from httpx import AsyncClient
  13. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  14. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  15. TAG = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
  16. @pytest.fixture
  17. async def spoolman_settings(db_session):
  18. from backend.app.models.settings import Settings
  19. db_session.add(Settings(key="spoolman_enabled", value="true"))
  20. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  21. await db_session.commit()
  22. @pytest.fixture
  23. def client():
  24. c = MagicMock()
  25. c.base_url = "http://localhost:7912"
  26. c.health_check = AsyncMock(return_value=True)
  27. c.has_tag_api = AsyncMock(return_value=False)
  28. c.add_native_tags = AsyncMock(return_value=0)
  29. c.unlink_all_native_tags = AsyncMock()
  30. c.ensure_tag_extra_field = AsyncMock(return_value=True)
  31. c.ensure_extra_field = AsyncMock(return_value=True)
  32. c.get_spools = AsyncMock(return_value=[])
  33. c.get_spool = AsyncMock(return_value={"id": 5, "archived": False, "extra": {}})
  34. c.merge_spool_extra = AsyncMock(return_value={"id": 5, "extra": {"tag": f'"{TAG}"'}})
  35. with (
  36. patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=c)),
  37. patch("backend.app.api.routes.spoolman.init_spoolman_client", AsyncMock(return_value=c)),
  38. patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=c)),
  39. patch("backend.app.api.routes.spoolman.printer_manager.get_client", MagicMock(return_value=None)),
  40. ):
  41. yield c
  42. @pytest.fixture
  43. def ws():
  44. """One websocket for both route modules: the announcement helper lives in
  45. spoolman_inventory, the link routes in spoolman."""
  46. w = MagicMock()
  47. w.broadcast = AsyncMock()
  48. with (
  49. patch("backend.app.api.routes.spoolman_inventory.ws_manager", w),
  50. patch("backend.app.api.routes.spoolman.ws_manager", w),
  51. ):
  52. yield w
  53. def _slots(ws) -> set[tuple[int, int, int]]:
  54. return {
  55. (c.args[0]["printer_id"], c.args[0]["ams_id"], c.args[0]["tray_id"])
  56. for c in ws.broadcast.await_args_list
  57. if c.args[0].get("type") == "spool_assignment_changed"
  58. }
  59. def _inventory_changed(ws) -> bool:
  60. return any(c.args[0].get("type") == "inventory_changed" for c in ws.broadcast.await_args_list)
  61. async def _row(db, printer_id: int, ams_id: int, tray_id: int, spool_id: int = 5) -> None:
  62. db.add(SpoolmanSlotAssignment(printer_id=printer_id, ams_id=ams_id, tray_id=tray_id, spoolman_spool_id=spool_id))
  63. await db.commit()
  64. async def test_unassign_announces_every_slot_it_cleared(
  65. async_client: AsyncClient, db_session, printer_factory, spoolman_settings, client, ws
  66. ):
  67. here = await printer_factory(name="Here")
  68. there = await printer_factory(name="There")
  69. await _row(db_session, here.id, 0, 0)
  70. await _row(db_session, there.id, 1, 2)
  71. await _row(db_session, there.id, 0, 3, spool_id=6) # another spool's slot
  72. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/5")
  73. assert response.status_code == 200, response.text
  74. assert _slots(ws) == {(here.id, 0, 0), (there.id, 1, 2)}
  75. async def test_unassign_of_a_spool_in_no_slot_announces_nothing(
  76. async_client: AsyncClient, db_session, printer_factory, spoolman_settings, client, ws
  77. ):
  78. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/5")
  79. assert response.status_code == 200, response.text
  80. assert _slots(ws) == set()
  81. async def test_a_link_onto_a_slot_announces_the_slot_and_the_tag(
  82. async_client: AsyncClient, db_session, printer_factory, spoolman_settings, client, ws
  83. ):
  84. printer = await printer_factory()
  85. response = await async_client.post(
  86. "/api/v1/spoolman/spools/5/link",
  87. json={"tray_uuid": TAG, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
  88. )
  89. assert response.status_code == 200, response.text
  90. assert _slots(ws) == {(printer.id, 0, 1)}
  91. assert _inventory_changed(ws)
  92. async def test_a_tag_only_link_announces_the_tag(async_client: AsyncClient, db_session, spoolman_settings, client, ws):
  93. """No slot row, but the slot cards find a spool by its tag."""
  94. response = await async_client.post("/api/v1/spoolman/spools/5/link", json={"tray_uuid": TAG})
  95. assert response.status_code == 200, response.text
  96. assert _slots(ws) == set()
  97. assert _inventory_changed(ws)
  98. async def test_unlink_announces_the_slots_it_cleared(
  99. async_client: AsyncClient, db_session, printer_factory, spoolman_settings, client, ws
  100. ):
  101. printer = await printer_factory()
  102. await _row(db_session, printer.id, 0, 2)
  103. client.merge_spool_extra = AsyncMock(return_value={"id": 5, "extra": {"tag": '""'}})
  104. response = await async_client.post("/api/v1/spoolman/spools/5/unlink")
  105. assert response.status_code == 200, response.text
  106. assert _slots(ws) == {(printer.id, 0, 2)}
  107. assert _inventory_changed(ws)
  108. async def test_a_spool_created_from_a_slot_is_announced(
  109. async_client: AsyncClient, db_session, printer_factory, spoolman_settings, client, ws
  110. ):
  111. printer = await printer_factory()
  112. tray = {"id": 3, "tag_uid": "0102030405060708", "tray_uuid": TAG, "tray_type": "PLA"}
  113. status = SimpleNamespace(raw_data={"ams": [{"id": 0, "tray": [tray]}]})
  114. client.parse_ams_tray = MagicMock(return_value=SimpleNamespace(tag_uid=tray["tag_uid"], tray_uuid=TAG))
  115. client.sync_ams_tray = AsyncMock(return_value={"id": 9})
  116. with patch("backend.app.api.routes.spoolman.printer_manager.get_status", MagicMock(return_value=status)):
  117. response = await async_client.post(
  118. "/api/v1/spoolman/spools/from-slot", json={"printer_id": printer.id, "ams_id": 0, "tray_id": 3}
  119. )
  120. assert response.status_code == 200, response.text
  121. assert _slots(ws) == {(printer.id, 0, 3)}
  122. assert _inventory_changed(ws)
  123. async def test_the_assign_announces_after_the_previous_spool_lost_the_slots_tag(
  124. async_client: AsyncClient, db_session, printer_factory, spoolman_settings, client, ws
  125. ):
  126. """The reported path: a page that refetches on the announcement must not
  127. read the previous spool still holding the slot's fallback tag."""
  128. import json
  129. from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
  130. printer = await printer_factory(serial_number="01S00C123456789")
  131. fallback = get_fallback_spool_tag_for_slot(printer.serial_number, 0, 0)
  132. previous = {"id": 32, "archived": False, "extra": {"tag": json.dumps(fallback)}}
  133. client.get_spools = AsyncMock(return_value=[previous])
  134. async def _merge(spool_id, fields):
  135. if spool_id == 32:
  136. previous["extra"] = {**previous["extra"], **fields}
  137. return {"id": spool_id, "extra": fields}
  138. client.merge_spool_extra = AsyncMock(side_effect=_merge)
  139. tag_when_announced: list[str] = []
  140. async def _broadcast(message):
  141. if message.get("type") == "spool_assignment_changed":
  142. tag_when_announced.append(json.loads(previous["extra"]["tag"]))
  143. ws.broadcast = AsyncMock(side_effect=_broadcast)
  144. with patch("backend.app.api.routes.spoolman_inventory.printer_manager.get_client", MagicMock(return_value=None)):
  145. response = await async_client.post(
  146. "/api/v1/spoolman/inventory/slot-assignments",
  147. json={"spoolman_spool_id": 5, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  148. )
  149. assert response.status_code == 200, response.text
  150. assert tag_when_announced == [""], "announced while spool #32 still held the slot's tag"