test_spoolman_slot_mapping_fallback.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. """Slot-to-tray mapping fallbacks on the Spoolman path (#2768).
  2. Bambuddy only learns a print's slot-to-tray mapping at print start when it can
  3. intercept the command on the printer's local MQTT request topic, or when the
  4. print came from its own queue. A print dispatched from Bambu Studio while the
  5. printer is cloud-bound satisfies neither: the command travels through Bambu's
  6. broker, so ``ActivePrintSpoolman.slot_to_tray`` is NULL and every slot falls
  7. through to a positional guess (slicer slot 1 to the first loaded tray, and so
  8. on). The reporter's X1C was loaded out of slicer order, so all four slots were
  9. charged to the wrong spool and the archive's filament was rewritten to match.
  10. The internal-inventory writer never had this problem because it resolves the
  11. mapping at completion, where it can read the printer's own ``mapping`` field or
  12. colour-match the 3MF slots against the loaded trays. These tests cover giving
  13. the Spoolman writer the same two fallbacks.
  14. """
  15. from types import SimpleNamespace
  16. from unittest.mock import AsyncMock, MagicMock, patch
  17. import pytest
  18. from backend.app.services.spoolman_tracking import _resolve_slot_to_tray_fallback
  19. class _AsyncCtx:
  20. """Minimal async context manager yielding a stub db session."""
  21. def __init__(self, db):
  22. self._db = db
  23. async def __aenter__(self):
  24. return self._db
  25. async def __aexit__(self, *exc):
  26. return False
  27. def _state(**raw):
  28. return SimpleNamespace(raw_data=raw, layer_num=0, total_layers=0, tray_change_log=[])
  29. def _patched_pm(state):
  30. pm = MagicMock()
  31. pm.get_status.return_value = state
  32. return pm
  33. class TestResolveSlotToTrayFallback:
  34. def test_decodes_the_printers_own_mapping_field(self):
  35. """The reporter's X1C published mapping=[1, 3, 0, 32768] while their
  36. AMS was loaded out of slicer order. Snow-encoded, that is AMS 0 slot 2,
  37. AMS 0 slot 4, AMS 0 slot 1, and the AMS-HT — nothing like the
  38. positional [0, 1, 2, 3] the fallback-free path assumed."""
  39. pm = _patched_pm(_state(mapping=[1, 3, 0, 32768]))
  40. with patch("backend.app.services.printer_manager.printer_manager", pm):
  41. mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
  42. assert mapping == [1, 3, 0, 128]
  43. assert source == "mqtt"
  44. def test_colour_matches_when_the_printer_publishes_no_mapping(self):
  45. """A1/P1S/P2S never publish the mapping field. The 3MF's per-slot
  46. colours still identify the trays when each one is unambiguous."""
  47. pm = _patched_pm(
  48. _state(
  49. ams=[
  50. {
  51. "id": 0,
  52. "tray": [
  53. {"id": 0, "tray_color": "00FF00FF", "tray_type": "PLA"},
  54. {"id": 1, "tray_color": "FF0000FF", "tray_type": "PLA"},
  55. ],
  56. }
  57. ]
  58. )
  59. )
  60. usage = [{"slot_id": 1, "color": "#FF0000"}, {"slot_id": 2, "color": "#00FF00"}]
  61. with patch("backend.app.services.printer_manager.printer_manager", pm):
  62. mapping, source = _resolve_slot_to_tray_fallback(1, usage)
  63. assert mapping == [1, 0]
  64. assert source == "color_match"
  65. def test_mapping_field_wins_over_colour_matching(self):
  66. """The printer's own field is direct evidence; colour matching is
  67. inference. When both are available the field decides."""
  68. pm = _patched_pm(
  69. _state(
  70. mapping=[3],
  71. ams=[{"id": 0, "tray": [{"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"}]}],
  72. )
  73. )
  74. with patch("backend.app.services.printer_manager.printer_manager", pm):
  75. mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
  76. assert mapping == [3]
  77. assert source == "mqtt"
  78. def test_reports_none_when_neither_fallback_answers(self):
  79. """Ambiguous colours and no mapping field: say so rather than invent
  80. one. The caller keeps the positional default, which is no worse than
  81. before, and the log names the reason."""
  82. pm = _patched_pm(
  83. _state(
  84. ams=[
  85. {
  86. "id": 0,
  87. "tray": [
  88. {"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"},
  89. {"id": 1, "tray_color": "FF0000FF", "tray_type": "PLA"},
  90. ],
  91. }
  92. ]
  93. )
  94. )
  95. with patch("backend.app.services.printer_manager.printer_manager", pm):
  96. mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
  97. assert mapping is None
  98. assert source == "none"
  99. def test_reports_none_when_the_printer_is_offline(self):
  100. """No live state at completion — the printer dropped off after the
  101. print. Nothing to read, and no crash."""
  102. with patch("backend.app.services.printer_manager.printer_manager", _patched_pm(None)):
  103. mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
  104. assert mapping is None
  105. assert source == "none"
  106. class TestReportUsageUsesTheFallback:
  107. """End-to-end through report_usage: the fallback has to reach
  108. ``_resolve_global_tray_id`` and change which spool is charged."""
  109. @staticmethod
  110. def _run(tracking, state, spool_by_tag, archive):
  111. # The first SELECT fetches the tracking row; every later one fetches the
  112. # archive for the colour / type rewrites (#1494, #2563).
  113. rows = iter([tracking])
  114. def _next_row(*_args, **_kwargs):
  115. result = MagicMock()
  116. result.scalar_one_or_none.return_value = next(rows, archive)
  117. return result
  118. db = AsyncMock()
  119. db.execute = AsyncMock(side_effect=_next_row)
  120. db.delete = AsyncMock()
  121. db.commit = AsyncMock()
  122. client = AsyncMock()
  123. client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: spool_by_tag.get(tag))
  124. client.use_spool = AsyncMock()
  125. pm = _patched_pm(state)
  126. async def _go():
  127. from backend.app.services.spoolman_tracking import report_usage
  128. with (
  129. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  130. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  131. patch(
  132. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  133. AsyncMock(return_value=client),
  134. ),
  135. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SER")),
  136. patch(
  137. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  138. AsyncMock(return_value=None),
  139. ),
  140. patch("backend.app.services.printer_manager.printer_manager", pm),
  141. ):
  142. await report_usage(printer_id=1, archive_id=42)
  143. return _go, client
  144. @pytest.mark.asyncio
  145. async def test_mqtt_mapping_charges_the_tray_the_printer_named(self):
  146. """One-slot print whose filament actually came from AMS slot 4
  147. (global tray 3). With no stored mapping the positional default charges
  148. global tray 0 — the wrong spool, and the archive is then rewritten to
  149. that spool's colour. The printer's mapping field says otherwise."""
  150. tracking = SimpleNamespace(
  151. filament_usage=[{"slot_id": 1, "used_g": 25.0, "type": "PLA", "color": "#FF0000"}],
  152. ams_trays={
  153. "0": {"tray_uuid": "TRAY0UUID", "tag_uid": "", "tray_type": "PLA"},
  154. "3": {"tray_uuid": "TRAY3UUID", "tag_uid": "", "tray_type": "PLA"},
  155. },
  156. slot_to_tray=None,
  157. tray_remain_start=None,
  158. layer_usage=None,
  159. filament_properties=None,
  160. )
  161. state = _state(mapping=[3])
  162. spools = {
  163. "TRAY0UUID": {"id": 100, "filament": {"color_hex": "FFFFFF", "material": "PLA"}},
  164. "TRAY3UUID": {"id": 300, "filament": {"color_hex": "FF0000", "material": "PLA"}},
  165. }
  166. archive = SimpleNamespace(filament_color="#FF0000", filament_type="PLA")
  167. run, client = self._run(tracking, state, spools, archive)
  168. await run()
  169. client.use_spool.assert_awaited_once_with(300, 25.0)
  170. # And the visible half of the bug: the archive keeps the red it was
  171. # printed in instead of being rewritten to the wrong spool's white.
  172. assert archive.filament_color == "#FF0000"
  173. @pytest.mark.asyncio
  174. async def test_a_stored_mapping_is_never_second_guessed(self):
  175. """Print start captured the real ams_mapping (LAN print, or a Bambuddy
  176. queue job). That is the slicer's own instruction and outranks anything
  177. read back off the printer, whose mapping field may still describe an
  178. earlier job."""
  179. tracking = SimpleNamespace(
  180. filament_usage=[{"slot_id": 1, "used_g": 25.0, "type": "PLA", "color": "#FF0000"}],
  181. ams_trays={
  182. "0": {"tray_uuid": "TRAY0UUID", "tag_uid": "", "tray_type": "PLA"},
  183. "3": {"tray_uuid": "TRAY3UUID", "tag_uid": "", "tray_type": "PLA"},
  184. },
  185. slot_to_tray=[0],
  186. tray_remain_start=None,
  187. layer_usage=None,
  188. filament_properties=None,
  189. )
  190. state = _state(mapping=[3])
  191. spools = {
  192. "TRAY0UUID": {"id": 100, "filament": {"color_hex": "FFFFFF", "material": "PLA"}},
  193. "TRAY3UUID": {"id": 300, "filament": {"color_hex": "FF0000", "material": "PLA"}},
  194. }
  195. archive = SimpleNamespace(filament_color="#FF0000", filament_type="PLA")
  196. run, client = self._run(tracking, state, spools, archive)
  197. await run()
  198. client.use_spool.assert_awaited_once_with(100, 25.0)