test_spoolman_ams_sync_broadcast.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. """Spoolman's AMS sync has to tell the browser the slot changed.
  2. Swap a spool and the AMS slot card keeps showing the *previous* spool's preset
  3. name. The card reads ``slot_preset_mappings.preset_name`` ahead of the live
  4. ``tray_info_idx``, so a cached row wins over correct data pushed over the
  5. socket — and everything else on the card rides the status push, which is why it
  6. surfaces as one wrong line rather than an obviously stale card.
  7. Built-in inventory raised ``spool_auto_assigned`` for this (the frontend simply
  8. forgot to invalidate ``slotPresets`` on it). This loop raised nothing at all,
  9. even though it rewrites the very same row through
  10. ``upsert_slot_preset_for_spoolman_spool`` — so in Spoolman mode there was no
  11. event to hang an invalidation on, and the stale name stood until an unrelated
  12. refetch.
  13. An emptied slot counts: its ``spoolman_slot_assignments`` row is deleted here,
  14. and a card still drawing the removed spool is the same defect.
  15. """
  16. from unittest.mock import AsyncMock, MagicMock, patch
  17. import pytest
  18. from httpx import AsyncClient
  19. from sqlalchemy.ext.asyncio import AsyncSession
  20. from backend.app.models.settings import Settings
  21. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  22. def _status(ams_data):
  23. status = MagicMock()
  24. status.raw_data = {"ams": ams_data, "vt_tray": []}
  25. status.gcode_state = "IDLE"
  26. return status
  27. def _tray(ams_id: int, tray_id: int):
  28. """What ``client.parse_ams_tray`` hands back for an occupied slot."""
  29. tray = MagicMock()
  30. tray.ams_id = ams_id
  31. tray.tray_id = tray_id
  32. tray.tray_uuid = "EB335968299543078925C71D83DA3864"
  33. tray.tag_uid = "7757EF0100000100"
  34. tray.tray_info_idx = "GFA01"
  35. tray.tray_type = "PLA"
  36. tray.tray_sub_brands = "PLA Matte"
  37. tray.tray_color = "042F56FF"
  38. return tray
  39. async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
  40. """Drive ``on_ams_change`` with Spoolman standing in for the real server.
  41. ``parsed`` maps (ams_id, tray_id) to a parsed tray or ``None`` (empty slot),
  42. which is the only thing that decides whether the sync treats the slot as
  43. occupied or cleared.
  44. """
  45. from backend.app.main import on_ams_change
  46. spoolman = MagicMock()
  47. spoolman.health_check = AsyncMock(return_value=True)
  48. spoolman.get_spools = AsyncMock(return_value=[])
  49. spoolman.parse_ams_tray = MagicMock(
  50. side_effect=lambda ams_id, tray_data: parsed.get((ams_id, int(tray_data.get("id", 0))))
  51. )
  52. spoolman.sync_ams_tray = AsyncMock(return_value={"id": 4242})
  53. status = _status(ams_data)
  54. with (
  55. patch("backend.app.main.printer_manager") as pm_main,
  56. patch("backend.app.services.printer_manager.printer_manager") as pm_inv,
  57. patch("backend.app.main.mqtt_relay") as relay,
  58. patch("backend.app.main.ws_manager") as ws,
  59. patch("backend.app.main.get_spoolman_client", AsyncMock(return_value=spoolman)),
  60. patch(
  61. "backend.app.services.slot_preset_writer.upsert_slot_preset_for_spoolman_spool",
  62. AsyncMock(),
  63. ),
  64. ):
  65. pm_main.get_printer.return_value = MagicMock(name="P", serial_number="SER")
  66. pm_main.get_status.return_value = status
  67. pm_main.get_client.return_value = MagicMock()
  68. pm_main.get_model.return_value = "X1C"
  69. pm_inv.get_status.return_value = status
  70. pm_inv.get_client.return_value = MagicMock()
  71. relay.on_ams_change = AsyncMock()
  72. ws.send_printer_status = AsyncMock()
  73. ws.broadcast = AsyncMock()
  74. await on_ams_change(printer_id, ams_data)
  75. return ws.broadcast, spoolman
  76. def _slot_events(broadcast) -> set[tuple[int, int]]:
  77. """(ams_id, tray_id) of every assignment-changed event that went out."""
  78. return {
  79. (call.args[0]["ams_id"], call.args[0]["tray_id"])
  80. for call in broadcast.call_args_list
  81. if call.args and call.args[0].get("type") == "spool_assignment_changed"
  82. }
  83. async def _enable_spoolman(db: AsyncSession) -> None:
  84. db.add(Settings(key="spoolman_enabled", value="true"))
  85. db.add(Settings(key="spoolman_url", value="http://spoolman.invalid:7912"))
  86. await db.commit()
  87. @pytest.mark.asyncio
  88. @pytest.mark.integration
  89. async def test_a_synced_slot_is_broadcast(async_client: AsyncClient, printer_factory, db_session: AsyncSession):
  90. printer = await printer_factory(name="H2C")
  91. await _enable_spoolman(db_session)
  92. broadcast, spoolman = await _run_ams_change(
  93. printer.id,
  94. [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "042F56FF", "state": 11}]}],
  95. parsed={(0, 0): _tray(0, 0)},
  96. )
  97. spoolman.sync_ams_tray.assert_awaited()
  98. assert (0, 0) in _slot_events(broadcast)
  99. @pytest.mark.asyncio
  100. @pytest.mark.integration
  101. async def test_an_emptied_slot_is_broadcast(async_client: AsyncClient, printer_factory, db_session: AsyncSession):
  102. """The row is deleted here; a card still drawing the removed spool is the
  103. same bug seen from the other side."""
  104. printer = await printer_factory(name="H2C")
  105. await _enable_spoolman(db_session)
  106. db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=1, spoolman_spool_id=7))
  107. await db_session.commit()
  108. broadcast, _ = await _run_ams_change(
  109. printer.id,
  110. [{"id": 0, "tray": [{"id": 1}]}],
  111. parsed={(0, 1): None},
  112. )
  113. assert (0, 1) in _slot_events(broadcast)
  114. @pytest.mark.asyncio
  115. @pytest.mark.integration
  116. async def test_nothing_is_broadcast_when_no_slot_changed(
  117. async_client: AsyncClient, printer_factory, db_session: AsyncSession
  118. ):
  119. """A push that changes no slot must stay quiet — this runs on every AMS
  120. message, and an event per push would invalidate the browser's caches
  121. continuously."""
  122. printer = await printer_factory(name="H2C")
  123. await _enable_spoolman(db_session)
  124. broadcast, spoolman = await _run_ams_change(
  125. printer.id,
  126. [{"id": 0, "tray": []}],
  127. parsed={},
  128. )
  129. # Guards the assertion below against passing because the sync bailed out
  130. # early on a mis-set fixture rather than because it found nothing to say.
  131. spoolman.get_spools.assert_awaited()
  132. assert _slot_events(broadcast) == set()