test_mode_toggle_keeps_assignments_2812.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. """The inventory mode toggle is no longer destructive (#2812).
  2. Turning Spoolman mode on ran an unfiltered ``delete(SpoolAssignment)`` across
  3. every printer. Turning it straight back off cleared the *other* table instead,
  4. so the built-in assignments were simply gone -- and the setting auto-saves on a
  5. 500 ms debounce with no confirmation, so inspecting the mode destroyed the
  6. configuration. The reporter toggled four times in 85 seconds and never got
  7. their assignments back.
  8. The deletion had a real reason: checks that read both assignment tables would
  9. otherwise let a row in the mode you are *not* using answer for the mode you
  10. are. The fix is to make those checks ask which mode is active, which is where
  11. that decision belongs, and then stop deleting.
  12. """
  13. from types import SimpleNamespace
  14. from unittest.mock import AsyncMock, patch
  15. import pytest
  16. from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
  17. class _Result:
  18. def __init__(self, value):
  19. self._value = value
  20. def scalar_one_or_none(self):
  21. return self._value
  22. def scalars(self):
  23. return self
  24. def first(self):
  25. return self._value
  26. def all(self):
  27. return [self._value] if self._value is not None else []
  28. class _TableRoutingSession:
  29. """Returns a row per model, so a test can populate either table or both."""
  30. def __init__(self, rows: dict):
  31. self._rows = rows
  32. self.queried = []
  33. async def execute(self, stmt):
  34. entity = stmt.column_descriptions[0]["entity"]
  35. name = entity.__name__
  36. self.queried.append(name)
  37. return _Result(self._rows.get(name))
  38. class TestKProfileIgnoresTheInactiveModesTable:
  39. """slot_kprofile checked the built-in table first and, on a hit with no
  40. matching profile, deliberately returned None rather than falling through to
  41. Spoolman. That was safe only while the built-in table was guaranteed empty
  42. in Spoolman mode. A leftover row would otherwise shadow the Spoolman
  43. binding -- the symptom #1556 reported from the other direction.
  44. """
  45. @pytest.mark.asyncio
  46. async def test_spoolman_mode_does_not_read_the_built_in_table(self):
  47. session = _TableRoutingSession(
  48. {
  49. # A leftover from before the user switched modes.
  50. "SpoolAssignment": SimpleNamespace(spool_id=7),
  51. "SpoolmanSlotAssignment": None,
  52. }
  53. )
  54. with patch(
  55. "backend.app.services.slot_kprofile.spoolman_owns_assignments",
  56. new_callable=AsyncMock,
  57. return_value=True,
  58. ):
  59. result = await find_slot_kprofile_for_extruder(
  60. session, printer_id=1, ams_id=0, tray_id=0, extruder=0, nozzle_diameter="0.4"
  61. )
  62. assert result is None
  63. assert "SpoolAssignment" not in session.queried
  64. @pytest.mark.asyncio
  65. async def test_built_in_mode_does_not_read_the_spoolman_table(self):
  66. session = _TableRoutingSession(
  67. {
  68. "SpoolAssignment": None,
  69. "SpoolmanSlotAssignment": SimpleNamespace(spoolman_spool_id=9),
  70. }
  71. )
  72. with patch(
  73. "backend.app.services.slot_kprofile.spoolman_owns_assignments",
  74. new_callable=AsyncMock,
  75. return_value=False,
  76. ):
  77. result = await find_slot_kprofile_for_extruder(
  78. session, printer_id=1, ams_id=0, tray_id=0, extruder=0, nozzle_diameter="0.4"
  79. )
  80. assert result is None
  81. assert "SpoolmanSlotAssignment" not in session.queried
  82. class TestCostEstimateIgnoresTheInactiveModesTable:
  83. """A leftover built-in assignment must not price a pre-print estimate from
  84. a spool the printer is not drawing on. The default rate is the honest
  85. answer once the mode has moved on."""
  86. @staticmethod
  87. async def _estimate(spoolman_mode: bool):
  88. from backend.app.services import print_cost_estimate as pce
  89. library_file = SimpleNamespace(
  90. file_path="nowhere/never.3mf",
  91. file_metadata={"filament_used_grams": 100.0},
  92. source_folder=None,
  93. )
  94. db = AsyncMock()
  95. db.execute = AsyncMock(side_effect=AssertionError("the built-in table must not be queried"))
  96. with (
  97. patch(
  98. "backend.app.services.print_cost_estimate.spoolman_owns_assignments",
  99. new_callable=AsyncMock,
  100. return_value=spoolman_mode,
  101. ),
  102. patch(
  103. "backend.app.services.print_cost_estimate._default_cost_per_kg",
  104. new_callable=AsyncMock,
  105. return_value=25.0,
  106. ),
  107. patch(
  108. "backend.app.services.print_cost_estimate._source_path",
  109. return_value=__import__("pathlib").Path("/nonexistent/never.3mf"),
  110. ),
  111. ):
  112. return await pce.estimate_queue_source_cost(
  113. db,
  114. library_file=library_file,
  115. ams_mapping=[0],
  116. printer_id=1,
  117. )
  118. @pytest.mark.asyncio
  119. async def test_spoolman_mode_does_not_price_from_built_in_spools(self):
  120. # 100 g at the 25/kg default. The AsyncMock would raise if the
  121. # built-in assignment table were queried.
  122. assert await self._estimate(spoolman_mode=True) == pytest.approx(2.5)
  123. @pytest.mark.asyncio
  124. async def test_built_in_mode_still_reads_its_own_table(self):
  125. """The guard must not switch the built-in path off as well."""
  126. with pytest.raises(AssertionError, match="must not be queried"):
  127. await self._estimate(spoolman_mode=False)
  128. class TestCompletionNotifiesTheLostDebit:
  129. """The half that turned a mis-click into lost filament.
  130. A print whose assignments existed at print start and were gone by the time
  131. it finished resolved its 3MF, resolved its grams, resolved its tray, and
  132. then skipped the debit because the row was missing -- at INFO, with no
  133. notification, while the completion notification fired as usual. The
  134. reporter's 65.49 g was never deducted and nothing surfaced it.
  135. The print-start check cannot cover this: it runs before the job and was
  136. correct to stay quiet, because at that moment the assignments existed.
  137. """
  138. @pytest.mark.asyncio
  139. async def test_a_skipped_debit_notifies_at_completion(self):
  140. from backend.app.services.spool_assignment_notifications import (
  141. notify_missing_spool_assignments_on_print_complete,
  142. )
  143. db = AsyncMock()
  144. db.get = AsyncMock(return_value=SimpleNamespace(name="Printer A"))
  145. logger = __import__("logging").getLogger(__name__)
  146. with (
  147. patch(
  148. "backend.app.services.spool_assignment_notifications.printer_manager.get_status",
  149. return_value=None,
  150. ),
  151. patch(
  152. "backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
  153. new_callable=AsyncMock,
  154. ) as mock_ws,
  155. patch(
  156. "backend.app.services.spool_assignment_notifications.notification_service."
  157. "on_print_missing_spool_assignment",
  158. new_callable=AsyncMock,
  159. ) as mock_notify,
  160. ):
  161. await notify_missing_spool_assignments_on_print_complete(1, [2], db, logger)
  162. mock_ws.assert_awaited_once()
  163. assert mock_ws.await_args.kwargs["missing_slots"] == [{"slot": "A3", "profile": "Unknown", "color": "Unknown"}]
  164. mock_notify.assert_awaited_once()
  165. @pytest.mark.asyncio
  166. async def test_a_print_that_debited_everything_stays_quiet(self):
  167. from backend.app.services.spool_assignment_notifications import (
  168. notify_missing_spool_assignments_on_print_complete,
  169. )
  170. db = AsyncMock()
  171. logger = __import__("logging").getLogger(__name__)
  172. with patch(
  173. "backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
  174. new_callable=AsyncMock,
  175. ) as mock_ws:
  176. await notify_missing_spool_assignments_on_print_complete(1, [], db, logger)
  177. mock_ws.assert_not_awaited()
  178. @pytest.mark.asyncio
  179. async def test_a_failure_here_never_fails_the_completed_print(self):
  180. """The print is already done and its spools already written. A
  181. notification that cannot be sent must not surface as a failed
  182. completion."""
  183. from backend.app.services.spool_assignment_notifications import (
  184. notify_missing_spool_assignments_on_print_complete,
  185. )
  186. db = AsyncMock()
  187. db.get = AsyncMock(side_effect=RuntimeError("db gone"))
  188. logger = __import__("logging").getLogger(__name__)
  189. await notify_missing_spool_assignments_on_print_complete(1, [2], db, logger)