test_spoolman_slot_sync_writes.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. """What the Spoolman AMS syncs write to the slot table, and announce (SM-C3, SSA-2, SSA-3).
  2. - The automatic pass read the slot table, then awaited Spoolman for every tray
  3. and wrote every synced slot back, unchanged ones included. A spool assigned
  4. by hand in between was overwritten with what the pass had read. It now writes
  5. only slots whose spool it changed, and only while they still hold what it read.
  6. - The Sync buttons in Settings wrote slots without announcing them, refreshing
  7. the slot's preset or clearing its grace hold, as the automatic pass does.
  8. - Both seeded a newly created spool's weight from the built-in inventory's
  9. slot assignments, which in Spoolman mode are left over from before the switch.
  10. """
  11. from types import SimpleNamespace
  12. from unittest.mock import AsyncMock, MagicMock, patch
  13. import pytest
  14. from httpx import AsyncClient
  15. from sqlalchemy import select, update
  16. from backend.app.models.spool import Spool
  17. from backend.app.models.spool_assignment import SpoolAssignment
  18. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  19. from backend.app.services.spoolman import AMSTray
  20. from backend.tests.integration.test_spoolman_ams_sync_broadcast import (
  21. _enable_spoolman,
  22. _run_ams_change,
  23. _slot_events,
  24. _tray,
  25. )
  26. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  27. async def _slots(db_session, printer_id) -> dict[tuple[int, int], int]:
  28. rows = (
  29. await db_session.execute(
  30. select(SpoolmanSlotAssignment)
  31. .where(SpoolmanSlotAssignment.printer_id == printer_id)
  32. .execution_options(populate_existing=True)
  33. )
  34. ).scalars()
  35. return {(row.ams_id, row.tray_id): row.spoolman_spool_id for row in rows}
  36. async def _printer_with_slot(db_session, printer_factory, spool_id: int = 7) -> int:
  37. await _enable_spoolman(db_session)
  38. printer = await printer_factory()
  39. db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=spool_id))
  40. await db_session.commit()
  41. return printer.id
  42. def _assigning_by_hand_meanwhile(db_session, printer_id, slot, spool_id, returns):
  43. """sync_ams_tray, during which the user assigns *spool_id* to *slot*."""
  44. async def sync(tray, *args, **kwargs):
  45. await db_session.execute(
  46. update(SpoolmanSlotAssignment)
  47. .where(
  48. SpoolmanSlotAssignment.printer_id == printer_id,
  49. SpoolmanSlotAssignment.ams_id == slot[0],
  50. SpoolmanSlotAssignment.tray_id == slot[1],
  51. )
  52. .values(spoolman_spool_id=spool_id)
  53. )
  54. await db_session.commit()
  55. return returns
  56. return AsyncMock(side_effect=sync)
  57. AMS = [{"id": 0, "tray": [{"id": 0}, {"id": 1}]}]
  58. @pytest.mark.parametrize("synced", [7, 8], ids=["unchanged", "changed"])
  59. async def test_the_automatic_pass_keeps_a_spool_assigned_meanwhile(
  60. async_client: AsyncClient, db_session, printer_factory, synced
  61. ):
  62. printer_id = await _printer_with_slot(db_session, printer_factory)
  63. sync = _assigning_by_hand_meanwhile(db_session, printer_id, (0, 0), 9, {"id": synced})
  64. broadcast, _client = await _run_ams_change(printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=sync)
  65. assert await _slots(db_session, printer_id) == {(0, 0): 9}
  66. assert (0, 0) not in _slot_events(broadcast), "nothing the pass wrote changed"
  67. async def test_the_automatic_pass_announces_no_slot_it_left_as_it_was(
  68. async_client: AsyncClient, db_session, printer_factory
  69. ):
  70. printer_id = await _printer_with_slot(db_session, printer_factory)
  71. broadcast, _client = await _run_ams_change(
  72. printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(return_value={"id": 7})
  73. )
  74. assert await _slots(db_session, printer_id) == {(0, 0): 7}
  75. assert (0, 0) not in _slot_events(broadcast)
  76. async def test_the_automatic_pass_still_writes_and_announces_what_it_changed(
  77. async_client: AsyncClient, db_session, printer_factory
  78. ):
  79. printer_id = await _printer_with_slot(db_session, printer_factory)
  80. broadcast, _client = await _run_ams_change(
  81. printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(return_value={"id": 8})
  82. )
  83. assert await _slots(db_session, printer_id) == {(0, 0): 8}
  84. assert _slot_events(broadcast) == {(0, 0)}
  85. # ── The Sync buttons ─────────────────────────────────────────────────────────
  86. PATHS = ["/api/v1/spoolman/sync/{printer_id}", "/api/v1/spoolman/sync-all"]
  87. def _parsed_tray(ams_id, tray_data):
  88. return AMSTray(
  89. ams_id=ams_id,
  90. tray_id=int(tray_data.get("id", 0)),
  91. tray_type="PLA",
  92. tray_sub_brands="PLA Matte",
  93. tray_color="042F56FF",
  94. remain=-1,
  95. tag_uid="7757EF0100000100",
  96. tray_uuid="EB335968299543078925C71D83DA3864",
  97. tray_info_idx="GFA01",
  98. tray_weight=1000,
  99. )
  100. async def _manual_sync(async_client, printer_id, path, synced: int, *, trays=None, sync=None):
  101. trays = trays or [{"id": 0, "tray_type": "PLA"}]
  102. state = SimpleNamespace(state="IDLE", raw_data={"ams": [{"id": 0, "tray": trays}]})
  103. client = MagicMock()
  104. client.health_check = AsyncMock(return_value=True)
  105. client.get_spools = AsyncMock(return_value=[])
  106. # A tray without a type reads empty.
  107. client.parse_ams_tray = MagicMock(
  108. side_effect=lambda ams_id, tray: _parsed_tray(ams_id, tray) if tray.get("tray_type") else None
  109. )
  110. client.sync_ams_tray = sync or AsyncMock(return_value={"id": synced})
  111. preset = AsyncMock()
  112. with (
  113. patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
  114. patch("backend.app.api.routes.spoolman.printer_manager") as pm,
  115. patch("backend.app.services.spoolman_slot_sync.ws_manager") as ws,
  116. patch("backend.app.services.slot_preset_writer.upsert_slot_preset_for_spoolman_spool", preset),
  117. patch("backend.app.services.slot_unlink_grace.forget_slot") as forget,
  118. ):
  119. ws.broadcast = AsyncMock()
  120. pm.get_status = MagicMock(return_value=state)
  121. pm.get_all_statuses = MagicMock(return_value={printer_id: state})
  122. response = await async_client.post(path.format(printer_id=printer_id))
  123. assert response.status_code == 200, response.text
  124. return client, ws.broadcast, preset, forget
  125. @pytest.mark.parametrize("path", PATHS)
  126. async def test_a_sync_button_announces_a_changed_slot(async_client: AsyncClient, db_session, printer_factory, path):
  127. printer_id = await _printer_with_slot(db_session, printer_factory)
  128. _client, broadcast, preset, forget = await _manual_sync(async_client, printer_id, path, synced=8)
  129. assert await _slots(db_session, printer_id) == {(0, 0): 8}
  130. assert _slot_events(broadcast) == {(0, 0)}
  131. assert preset.await_args.kwargs["spoolman_spool"] == {"id": 8}
  132. forget.assert_called_once_with(printer_id, 0, 0)
  133. @pytest.mark.parametrize("path", PATHS)
  134. async def test_a_sync_button_does_not_seed_a_spool_from_the_built_in_inventory(
  135. async_client: AsyncClient, db_session, printer_factory, path
  136. ):
  137. """A slot assignment left from before the switch to Spoolman: 880 g of
  138. 1000 used, nothing to do with the spool now in the slot."""
  139. printer_id = await _printer_with_slot(db_session, printer_factory)
  140. stale = Spool(material="PLA", label_weight=1000, weight_used=880)
  141. db_session.add(stale)
  142. await db_session.flush()
  143. db_session.add(SpoolAssignment(spool_id=stale.id, printer_id=printer_id, ams_id=0, tray_id=0))
  144. await db_session.commit()
  145. client, *_ = await _manual_sync(async_client, printer_id, path, synced=8)
  146. assert client.sync_ams_tray.await_args.kwargs.get("inventory_remaining") is None
  147. @pytest.mark.parametrize("path", PATHS)
  148. async def test_a_sync_button_keeps_a_slot_that_was_filled_meanwhile(
  149. async_client: AsyncClient, db_session, printer_factory, path
  150. ):
  151. """Slot 0 read empty, so the sync would unlink spool 7; by then it holds 9."""
  152. printer_id = await _printer_with_slot(db_session, printer_factory)
  153. sync = _assigning_by_hand_meanwhile(db_session, printer_id, (0, 0), 9, {"id": 5})
  154. await _manual_sync(
  155. async_client, printer_id, path, synced=5, trays=[{"id": 0}, {"id": 1, "tray_type": "PLA"}], sync=sync
  156. )
  157. assert await _slots(db_session, printer_id) == {(0, 0): 9, (0, 1): 5}
  158. @pytest.mark.parametrize("path", PATHS)
  159. async def test_a_sync_button_still_unlinks_a_slot_that_reads_empty(
  160. async_client: AsyncClient, db_session, printer_factory, path
  161. ):
  162. printer_id = await _printer_with_slot(db_session, printer_factory)
  163. _client, broadcast, *_ = await _manual_sync(async_client, printer_id, path, synced=5, trays=[{"id": 0}])
  164. assert await _slots(db_session, printer_id) == {}
  165. assert _slot_events(broadcast) == {(0, 0)}
  166. async def test_the_automatic_pass_keeps_a_spool_assigned_to_a_slot_it_read_empty(
  167. async_client: AsyncClient, db_session, printer_factory
  168. ):
  169. await _enable_spoolman(db_session)
  170. printer_id = (await printer_factory()).id
  171. async def sync(tray, *args, **kwargs):
  172. db_session.add(SpoolmanSlotAssignment(printer_id=printer_id, ams_id=0, tray_id=0, spoolman_spool_id=9))
  173. await db_session.commit()
  174. return {"id": 8}
  175. await _run_ams_change(printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(side_effect=sync))
  176. assert await _slots(db_session, printer_id) == {(0, 0): 9}
  177. async def test_without_the_slot_table_as_read_the_syncs_write_as_before(db_session, printer_factory):
  178. """Its load failed: nothing to compare against, so a swap and a removal are
  179. written as before rather than dropped."""
  180. from backend.app.services.spoolman_slot_sync import write_slot_results
  181. printer_id = await _printer_with_slot(db_session, printer_factory, spool_id=10)
  182. db_session.add(SpoolmanSlotAssignment(printer_id=printer_id, ams_id=0, tray_id=1, spoolman_spool_id=11))
  183. await db_session.commit()
  184. with patch("backend.app.services.spoolman_slot_sync.ws_manager") as ws:
  185. ws.broadcast = AsyncMock()
  186. changed = await write_slot_results(db_session, None, [(printer_id, 0, 0, 99)], [(printer_id, 0, 1)])
  187. assert await _slots(db_session, printer_id) == {(0, 0): 99}
  188. assert sorted(changed) == [(printer_id, 0, 0), (printer_id, 0, 1)]