test_spoolman_tracking_slot_fallback.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """Integration tests for #1459 — per-print weight tracker falls back to the
  2. local spoolman_slot_assignments table when Spoolman's extra.tag is empty.
  3. Without this, tag-less spools assigned via the Bambuddy UI never get their
  4. weight decremented because the Assign route intentionally leaves extra.tag
  5. unset (per #1457 — fallback tags must not pollute Spoolman).
  6. """
  7. from unittest.mock import AsyncMock, MagicMock, patch
  8. import pytest
  9. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  10. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  11. from backend.app.services.spoolman_tracking import _report_spool_usage_for_slots
  12. @pytest.fixture
  13. def mock_spoolman_client():
  14. client = MagicMock()
  15. # Default: every tag-lookup returns None (the bug case — no extra.tag on Spoolman side).
  16. client.find_spool_by_tag = AsyncMock(return_value=None)
  17. client.use_spool = AsyncMock(return_value={"id": 0})
  18. return client
  19. @pytest.fixture
  20. def patch_async_session(test_engine):
  21. """Route the tracker's async_session() to the test engine so the slot-assignment
  22. fallback lookup sees rows committed via db_session in the same test."""
  23. test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  24. with patch("backend.app.services.spoolman_tracking.async_session", test_async_session):
  25. yield
  26. @pytest.fixture
  27. async def test_printer(db_session):
  28. from backend.app.models.printer import Printer
  29. printer = Printer(
  30. name="Tracking Test",
  31. serial_number="TRACKTEST123456",
  32. ip_address="192.168.0.99",
  33. access_code="12345678",
  34. model="P1S",
  35. is_active=True,
  36. auto_archive=True,
  37. )
  38. db_session.add(printer)
  39. await db_session.commit()
  40. await db_session.refresh(printer)
  41. return printer
  42. @pytest.mark.asyncio
  43. @pytest.mark.integration
  44. @pytest.mark.usefixtures("patch_async_session")
  45. class TestSlotAssignmentFallback:
  46. async def test_falls_back_to_slot_assignment_when_tag_missing(self, test_printer, mock_spoolman_client, db_session):
  47. """Tag-less spool assigned via Bambuddy UI: extra.tag is empty (find_spool_by_tag
  48. returns None) but the local spoolman_slot_assignments row says spool 42 lives in
  49. AMS 0 tray 2 — the tracker must still report usage to spool 42.
  50. slot_id is 1-based; ams_trays is keyed by global_tray_id. For AMS 0 tray 2,
  51. global_tray_id = 2, so we hand the tracker slot_id=3 (since slot_id-1=global=2).
  52. """
  53. db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=0, tray_id=2, spoolman_spool_id=42))
  54. await db_session.commit()
  55. ams_trays = {2: {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"}}
  56. usage_items = [(3, 15.5)]
  57. spools_updated = await _report_spool_usage_for_slots(
  58. mock_spoolman_client,
  59. usage_items,
  60. ams_trays,
  61. slot_to_tray=None,
  62. method_label="Test",
  63. printer_serial=test_printer.serial_number,
  64. printer_id=test_printer.id,
  65. )
  66. assert spools_updated == 1
  67. mock_spoolman_client.use_spool.assert_awaited_once_with(42, 15.5)
  68. async def test_tag_match_wins_over_slot_assignment(self, test_printer, mock_spoolman_client, db_session):
  69. """When both paths could resolve a spool, the tag-match wins — RFID is the
  70. authoritative binding when present. Order matters so RFID auto-sync continues
  71. to bind to the spool whose extra.tag literally holds that RFID, even if the
  72. slot-assignment table happens to point at a different spool."""
  73. db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=0, tray_id=0, spoolman_spool_id=999))
  74. await db_session.commit()
  75. mock_spoolman_client.find_spool_by_tag = AsyncMock(return_value={"id": 7})
  76. ams_trays = {0: {"tray_uuid": "A" * 32, "tag_uid": "", "tray_type": "PLA"}}
  77. # slot_id=1 → global_tray_id=0 (AMS 0 tray 0).
  78. usage_items = [(1, 10.0)]
  79. spools_updated = await _report_spool_usage_for_slots(
  80. mock_spoolman_client,
  81. usage_items,
  82. ams_trays,
  83. slot_to_tray=None,
  84. method_label="Test",
  85. printer_serial=test_printer.serial_number,
  86. printer_id=test_printer.id,
  87. )
  88. assert spools_updated == 1
  89. mock_spoolman_client.use_spool.assert_awaited_once_with(7, 10.0)
  90. async def test_skips_when_neither_path_resolves(self, test_printer, mock_spoolman_client, db_session):
  91. """No tag in Spoolman AND no slot-assignment row → tracker skips the slot
  92. rather than crashing or reporting against the wrong spool."""
  93. ams_trays = {0: {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"}}
  94. # slot_id=1 → global_tray_id=0 (AMS 0 tray 0); no assignment row exists.
  95. usage_items = [(1, 5.0)]
  96. spools_updated = await _report_spool_usage_for_slots(
  97. mock_spoolman_client,
  98. usage_items,
  99. ams_trays,
  100. slot_to_tray=None,
  101. method_label="Test",
  102. printer_serial=test_printer.serial_number,
  103. printer_id=test_printer.id,
  104. )
  105. assert spools_updated == 0
  106. mock_spoolman_client.use_spool.assert_not_called()
  107. async def test_skips_when_printer_id_not_supplied(self, test_printer, mock_spoolman_client, db_session):
  108. """Slot-assignment fallback requires printer_id to look up the binding —
  109. when callers don't supply it (legacy call shape) the lookup is skipped
  110. and the slot is reported as unresolved, matching pre-#1459 behaviour for
  111. those callers."""
  112. db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=0, tray_id=0, spoolman_spool_id=42))
  113. await db_session.commit()
  114. ams_trays = {0: {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"}}
  115. usage_items = [(1, 5.0)]
  116. spools_updated = await _report_spool_usage_for_slots(
  117. mock_spoolman_client,
  118. usage_items,
  119. ams_trays,
  120. slot_to_tray=None,
  121. method_label="Test",
  122. printer_serial=test_printer.serial_number,
  123. # printer_id omitted on purpose
  124. )
  125. assert spools_updated == 0
  126. mock_spoolman_client.use_spool.assert_not_called()
  127. async def test_external_slot_falls_back_via_correct_ams_tray_pair(
  128. self, test_printer, mock_spoolman_client, db_session
  129. ):
  130. """External spool slots use global_tray_id 254/255 which map to ams_id=255,
  131. tray_id=0/1. The slot-assignment lookup must use that translated pair, not the
  132. raw global id, otherwise the row is never found."""
  133. db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=255, tray_id=0, spoolman_spool_id=88))
  134. await db_session.commit()
  135. # Position-based default with ams_trays={254: ...}: sorted_tray_ids=[254],
  136. # slot_id=1 → sorted_tray_ids[0] = 254 (global) → ams_id=255 tray_id=0.
  137. ams_trays = {254: {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"}}
  138. usage_items = [(1, 25.0)]
  139. spools_updated = await _report_spool_usage_for_slots(
  140. mock_spoolman_client,
  141. usage_items,
  142. ams_trays,
  143. slot_to_tray=None,
  144. method_label="Test",
  145. printer_serial=test_printer.serial_number,
  146. printer_id=test_printer.id,
  147. )
  148. assert spools_updated == 1
  149. mock_spoolman_client.use_spool.assert_awaited_once_with(88, 25.0)