test_spoolman_unmapped_external_3405.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. """An X2D print from the right-hand external spool, sent without ams_mapping2 (#3405).
  2. The X2D has two external holders and lists both in ``vt_tray``, loaded or not.
  3. A print command that carries no ``ams_mapping2`` leaves the external spool as -1
  4. in the flat mapping (#3166 resolves it only from ams_mapping2). Until #3166's
  5. follow-up, Spoolman mode read that -1 as the first external holder present,
  6. 254, and charged the empty left holder; since then it charged nothing. The
  7. built-in inventory drops a mapping that names no tray for any used slot and
  8. lets the printer's ``tray_now`` decide. Spoolman mode now does the same, and
  9. reads ``tray_now`` 255 as the right-hand holder on a printer that has one, as
  10. the built-in inventory does.
  11. Values are the reporter's ``/status``: 254 empty, 255 loaded with red PLA,
  12. ``tray_now`` 255.
  13. """
  14. from types import SimpleNamespace
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from backend.app.services import spoolman_tracking
  18. from backend.app.services.spoolman_tracking import _single_slot_tray_from_state
  19. from backend.app.services.usage_tracker import has_second_external_holder, mapping_names_no_used_tray
  20. X2D_VT_TRAY = [
  21. {"id": 254, "tray_type": "", "tray_color": "00000000", "tray_info_idx": ""},
  22. {"id": 255, "tray_type": "PLA", "tray_color": "F72323FF", "tray_info_idx": "GFA01"},
  23. ]
  24. USAGE = [{"slot_id": 1, "used_g": 12.5, "type": "PLA", "color": "#F72323"}]
  25. RIGHT_HOLDER_SPOOL = 77
  26. def _x2d_state(tray_now=255, vt_tray=X2D_VT_TRAY, last_loaded_tray=-1, tray_change_log=(), **raw):
  27. return SimpleNamespace(
  28. raw_data={"vt_tray": vt_tray, **raw},
  29. layer_num=0,
  30. total_layers=0,
  31. tray_change_log=list(tray_change_log),
  32. tray_now=tray_now,
  33. last_loaded_tray=last_loaded_tray,
  34. )
  35. class TestTheRules:
  36. def test_a_mapping_of_only_minus_one_names_no_used_tray(self):
  37. assert mapping_names_no_used_tray([-1], USAGE)
  38. assert not mapping_names_no_used_tray([255], USAGE)
  39. # Another slot is mapped: the -1 is a slot the print does not feed.
  40. two = [*USAGE, {"slot_id": 2, "used_g": 3.0}]
  41. assert not mapping_names_no_used_tray([-1, 5], two)
  42. assert not mapping_names_no_used_tray(None, USAGE)
  43. def test_255_is_a_tray_only_on_a_printer_with_two_holders(self):
  44. assert has_second_external_holder({"vt_tray": X2D_VT_TRAY})
  45. assert not has_second_external_holder({"vt_tray": [X2D_VT_TRAY[0]]})
  46. assert not has_second_external_holder(None)
  47. def test_tray_now_255_is_the_right_holder(self):
  48. assert _single_slot_tray_from_state(_x2d_state(), USAGE, 255) == (1, 255)
  49. @pytest.mark.parametrize("stale", [3, 254])
  50. def test_the_right_holder_beats_a_tray_an_earlier_print_loaded(self, stale):
  51. """A print from 255 never updates last_loaded_tray: it still names the
  52. AMS tray, or the left holder, an earlier print used."""
  53. state = _x2d_state(last_loaded_tray=stale)
  54. assert _single_slot_tray_from_state(state, USAGE, 255) == (1, 255)
  55. def test_a_tray_loaded_during_the_print_still_wins_over_255(self):
  56. state = _x2d_state(last_loaded_tray=3, tray_change_log=[(3, 2)])
  57. assert _single_slot_tray_from_state(state, USAGE, 255) == (1, 3)
  58. def test_last_loaded_254_is_no_survivor(self):
  59. """Same cap as the built-in inventory: 0-253 only."""
  60. state = _x2d_state(vt_tray=[X2D_VT_TRAY[0]], last_loaded_tray=254)
  61. assert _single_slot_tray_from_state(state, USAGE, 255) is None
  62. def test_tray_now_255_on_a_one_holder_printer_is_nothing(self):
  63. assert _single_slot_tray_from_state(_x2d_state(vt_tray=[X2D_VT_TRAY[0]]), USAGE, 255) is None
  64. class _AsyncCtx:
  65. def __init__(self, db):
  66. self._db = db
  67. async def __aenter__(self):
  68. return self._db
  69. async def __aexit__(self, *exc):
  70. return False
  71. def _slot_assignment(_printer_id, ams_id, tray_id):
  72. # The reporter's spool, assigned to the holder it sits on: 255 is (255, 1).
  73. return RIGHT_HOLDER_SPOOL if (ams_id, tray_id) == (255, 1) else None
  74. def _client():
  75. client = AsyncMock()
  76. client.find_spool_by_tag = AsyncMock(return_value=None)
  77. client.get_spool = AsyncMock(
  78. return_value={"id": RIGHT_HOLDER_SPOOL, "filament": {"color_hex": "F72323", "material": "PLA"}}
  79. )
  80. client.use_spool = AsyncMock()
  81. return client
  82. def _patches(client, state, db):
  83. pm = MagicMock()
  84. pm.get_status.return_value = state
  85. return (
  86. patch.object(spoolman_tracking, "async_session", lambda: _AsyncCtx(db)),
  87. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  88. patch.object(spoolman_tracking, "_get_spoolman_client_with_fallback", AsyncMock(return_value=client)),
  89. patch.object(spoolman_tracking, "_get_printer_serial", AsyncMock(return_value="SER")),
  90. patch.object(
  91. spoolman_tracking, "_resolve_spool_id_via_slot_assignment", AsyncMock(side_effect=_slot_assignment)
  92. ),
  93. patch("backend.app.services.printer_manager.printer_manager", pm),
  94. )
  95. def _tracking(slot_to_tray):
  96. return SimpleNamespace(
  97. filament_usage=list(USAGE),
  98. ams_trays={
  99. "254": {"tray_uuid": "", "tag_uid": "", "tray_type": ""},
  100. "255": {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"},
  101. },
  102. slot_to_tray=slot_to_tray,
  103. tray_remain_start=None,
  104. layer_usage=None,
  105. filament_properties=None,
  106. tray_now_at_start=255,
  107. archive_id=12,
  108. )
  109. class TestReportUsage:
  110. @staticmethod
  111. async def _run(slot_to_tray, state=None):
  112. archive = SimpleNamespace(filament_color="#F72323", filament_type="PLA")
  113. rows = iter([_tracking(slot_to_tray)])
  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 = _client()
  123. p = _patches(client, state or _x2d_state(), db)
  124. with p[0], p[1], p[2], p[3], p[4], p[5]:
  125. await spoolman_tracking.report_usage(printer_id=1, archive_id=12)
  126. return client
  127. @pytest.mark.asyncio
  128. async def test_the_loaded_right_holder_is_charged(self):
  129. client = await self._run([-1])
  130. client.use_spool.assert_awaited_once_with(RIGHT_HOLDER_SPOOL, 12.5)
  131. @pytest.mark.asyncio
  132. async def test_a_same_coloured_ams_tray_is_not_charged_instead(self):
  133. """The colour match knows no external holder; the built-in inventory
  134. never runs it for a mapping that named no tray."""
  135. ams = [{"id": 0, "tray": [{"id": 0, "tray_color": "F72323FF", "tray_type": "PLA"}]}]
  136. client = await self._run([-1], state=_x2d_state(ams=ams))
  137. client.use_spool.assert_awaited_once_with(RIGHT_HOLDER_SPOOL, 12.5)
  138. @pytest.mark.asyncio
  139. async def test_a_resolved_mapping_is_unchanged(self):
  140. """With ams_mapping2 the captured mapping already says 255 (#3166)."""
  141. client = await self._run([255])
  142. client.use_spool.assert_awaited_once_with(RIGHT_HOLDER_SPOOL, 12.5)
  143. class TestPartialUsage:
  144. @pytest.mark.asyncio
  145. async def test_an_aborted_print_charges_the_right_holder_too(self):
  146. tracking = _tracking([-1])
  147. tracking.layer_usage = {}
  148. # A same-coloured AMS tray must not take it either, as on completion.
  149. ams = [{"id": 0, "tray": [{"id": 0, "tray_color": "F72323FF", "tray_type": "PLA"}]}]
  150. state = _x2d_state(ams=ams)
  151. state.layer_num, state.total_layers = 50, 100
  152. report = AsyncMock(return_value=1)
  153. p = _patches(_client(), state, AsyncMock())
  154. with (
  155. p[0],
  156. p[1],
  157. p[2],
  158. p[3],
  159. p[4],
  160. p[5],
  161. patch.object(spoolman_tracking, "_report_spool_usage_for_slots", report),
  162. ):
  163. await spoolman_tracking._report_partial_usage(1, tracking, last_progress=50)
  164. report.assert_awaited()
  165. slot_to_tray = report.await_args.args[3]
  166. assert spoolman_tracking._resolve_global_tray_id(1, slot_to_tray, {254: {}, 255: {}}) == 255