test_remain_delta_silence_1820.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. """The remain%-delta fallback must say when it charges nothing (#1820).
  2. The fallback exists so a print with no 3MF still moves the spool weight. On an
  3. H2S the AMS ``remain%`` it reads is too coarse and too noisy to carry that: the
  4. reporter measured it rising mid-print, swinging +/-5 points over one job,
  5. saturating at 100 on a fresh spool, and going negative near the end of one.
  6. Two of their prints wrote nothing, each for a different one of those reasons,
  7. and both looked identical from the outside -- ``no spools updated``, which is
  8. also what a print with genuinely nothing to charge prints. The arithmetic is a
  9. separate question; this is about not failing silently, so an operator can tell
  10. which prints need correcting by hand.
  11. """
  12. import logging
  13. import types
  14. import pytest
  15. from backend.app.services.spoolman_tracking import (
  16. _print_used_tray_keys,
  17. _report_remain_delta_for_slots,
  18. _snapshot_tray_remain,
  19. )
  20. pytestmark = pytest.mark.unit
  21. def _raw(remain, tray_uuid="uuid-a"):
  22. return {"ams": [{"id": 0, "tray": [{"id": 0, "remain": remain, "tray_uuid": tray_uuid}]}]}
  23. def _slot(remain, tray_uuid="uuid-a"):
  24. return {"0-0": {"remain": remain, "tray_uuid": tray_uuid}}
  25. class _Client:
  26. """Records anything the fallback tries to write."""
  27. def __init__(self):
  28. self.used = []
  29. async def get_spool(self, spool_id):
  30. return {"filament": {"weight": 1000}}
  31. async def use_spool(self, spool_id, grams):
  32. self.used.append((spool_id, grams))
  33. async def _run(caplog, **kwargs):
  34. client = _Client()
  35. with caplog.at_level(logging.INFO, logger="backend.app.services.spoolman_tracking"):
  36. written = await _report_remain_delta_for_slots(
  37. client,
  38. printer_id=1,
  39. handled_global_tray_ids=set(),
  40. archive_id=7,
  41. **kwargs,
  42. )
  43. return client, written, caplog.text
  44. class TestTheSnapshotGate:
  45. """A negative remain% -- what the AMS reports on a nearly empty spool --
  46. keeps the slot out of the snapshot entirely. That is how the reporter's
  47. second print lost the only slot that was printing."""
  48. def test_a_negative_remain_is_reported_as_skipped(self):
  49. skipped = []
  50. snapshot = _snapshot_tray_remain(_raw(-3), skipped)
  51. assert snapshot == {}
  52. assert skipped == ["AMS0-T0(remain=-3)"]
  53. def test_a_valid_remain_is_not_reported(self):
  54. skipped = []
  55. snapshot = _snapshot_tray_remain(_raw(42), skipped)
  56. assert snapshot == {"0-0": {"remain": 42, "tray_uuid": "uuid-a"}}
  57. assert skipped == []
  58. def test_the_external_spool_holder_is_reported_too(self):
  59. skipped = []
  60. _snapshot_tray_remain({"vt_tray": {"id": 254, "remain": -1}}, skipped)
  61. assert skipped == ["VT254(remain=-1)"]
  62. def test_the_collector_is_optional(self):
  63. """Two of the three call sites pass nothing; they must still work."""
  64. assert _snapshot_tray_remain(_raw(-3)) == {}
  65. @pytest.mark.asyncio
  66. class TestNothingCharged:
  67. async def test_a_spool_still_reading_full_is_reported(self, caplog):
  68. """The reporter's first print: 36 minutes on a fresh spool, 100% at
  69. both ends, so the delta was zero and the slot was skipped in silence."""
  70. client, written, text = await _run(caplog, tray_remain_start=_slot(100), current_lookup=_slot(100))
  71. assert written == 0
  72. assert client.used == []
  73. assert "did not fall" in text
  74. assert "100% -> 100%" in text
  75. async def test_a_reading_that_rose_is_reported(self, caplog):
  76. """remain% moving upward mid-print is noise, not a refill, but either
  77. way nothing is charged and the operator should hear about it."""
  78. _, written, text = await _run(caplog, tray_remain_start=_slot(12), current_lookup=_slot(17))
  79. assert written == 0
  80. assert "12% -> 17%" in text
  81. async def test_a_slot_missing_at_completion_is_reported(self, caplog):
  82. """The completion-side twin of the snapshot gate."""
  83. _, written, text = await _run(caplog, tray_remain_start=_slot(50), current_lookup={})
  84. assert written == 0
  85. assert "no valid remain" in text
  86. async def test_an_unassigned_slot_names_what_was_lost(self, caplog, monkeypatch):
  87. """It consumed something real and there is nowhere to put it, which is
  88. worth more than the debug line it used to get."""
  89. monkeypatch.setattr(
  90. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  91. _fake_resolver(None),
  92. )
  93. _, written, text = await _run(caplog, tray_remain_start=_slot(60), current_lookup=_slot(50))
  94. assert written == 0
  95. assert "no Spoolman slot assignment" in text
  96. assert "consumed 10%" in text
  97. @pytest.mark.asyncio
  98. class TestItStillWritesWhenItCan:
  99. async def test_a_real_drop_is_charged(self, caplog, monkeypatch):
  100. monkeypatch.setattr(
  101. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  102. _fake_resolver(42),
  103. )
  104. client, written, text = await _run(caplog, tray_remain_start=_slot(60), current_lookup=_slot(50))
  105. assert written == 1
  106. assert client.used == [(42, 100.0)] # 10% of a 1000 g reference weight
  107. assert "did not fall" not in text
  108. async def test_a_spool_swap_is_still_refused(self, caplog, monkeypatch):
  109. monkeypatch.setattr(
  110. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  111. _fake_resolver(42),
  112. )
  113. client, written, text = await _run(
  114. caplog,
  115. tray_remain_start=_slot(60, "uuid-a"),
  116. current_lookup=_slot(10, "uuid-b"),
  117. )
  118. assert written == 0
  119. assert client.used == []
  120. assert "swapped mid-print" in text
  121. def _fake_resolver(spool_id):
  122. async def _resolve(*_args, **_kwargs):
  123. return spool_id
  124. return _resolve
  125. class TestWhichSlotsThePrintUsed:
  126. """The guard the internal tracker has had since #1269, now on this path
  127. too. Without it a spool swapped into an idle slot mid-print reads as
  128. consumption and is charged to whatever that slot is assigned to."""
  129. def test_the_mapping_names_the_slots(self):
  130. """Global tray ids: 0-3 are AMS 0, 4-7 are AMS 1."""
  131. assert _print_used_tray_keys([0, 5], None, None) == {(0, 0), (1, 1)}
  132. def test_a_slicer_slot_routed_to_the_external_spool_is_ignored(self):
  133. """-1 means "external spool" in the flat mapping and names no AMS slot;
  134. the external holder arrives as 254/255 when it is really used."""
  135. assert _print_used_tray_keys([-1], None, None) == set()
  136. assert _print_used_tray_keys([254], None, None) == {(255, 0)}
  137. def test_an_ams_ht_keeps_its_own_id(self):
  138. assert _print_used_tray_keys([128], None, None) == {(128, 0)}
  139. def test_a_mid_print_tray_change_counts(self):
  140. """Filament backup switches trays mid-print; the substitute fed part of
  141. the job and has to be chargeable."""
  142. state = types.SimpleNamespace(tray_change_log=[[0, 0], [5, 120]])
  143. assert _print_used_tray_keys(None, None, state) == {(0, 0), (1, 1)}
  144. def test_the_tray_in_use_at_the_start_counts(self):
  145. """Often the only evidence: a print started from the printer's screen
  146. carries no mapping and may never change tray."""
  147. assert _print_used_tray_keys(None, 2, None) == {(0, 2)}
  148. def test_an_unloaded_printer_is_not_read_as_a_slot(self):
  149. """255 is what tray_now reads at rest -- its initial value, the
  150. unparseable-reading fallback, and "nothing loaded". Mapped as a tray id
  151. it becomes (255, 1), and as the only evidence it would exclude every
  152. real slot and charge nothing at all, which is this issue's own bug."""
  153. assert _print_used_tray_keys(None, 255, None) == set()
  154. def test_the_external_spool_in_use_is_a_slot(self):
  155. """It reports 254 when actually in use, which is a real slot."""
  156. assert _print_used_tray_keys(None, 254, None) == {(255, 0)}
  157. def test_no_evidence_at_all_yields_nothing(self):
  158. """Which callers must read as "consider every slot", not "no slots" --
  159. otherwise a printer reporting none of the three stops being tracked."""
  160. assert _print_used_tray_keys(None, None, None) == set()
  161. assert _print_used_tray_keys([], -1, types.SimpleNamespace(tray_change_log=[])) == set()
  162. def test_a_row_written_before_the_column_existed(self):
  163. """tray_now_at_start is nullable for exactly this reason."""
  164. assert _print_used_tray_keys([4], None, None) == {(1, 0)}
  165. @pytest.mark.asyncio
  166. class TestSlotsThePrintNeverTouched:
  167. async def test_an_untouched_slot_is_not_charged(self, caplog, monkeypatch):
  168. """A spool swapped into an idle slot drops that slot's remain%. Reading
  169. that as consumption is a phantom write to an uninvolved spool."""
  170. monkeypatch.setattr(
  171. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  172. _fake_resolver(42),
  173. )
  174. client, written, text = await _run(
  175. caplog,
  176. tray_remain_start=_slot(60),
  177. current_lookup=_slot(10),
  178. print_used_keys={(1, 3)}, # this print used AMS1-T3, not AMS0-T0
  179. )
  180. assert written == 0
  181. assert client.used == []
  182. assert "slots not part of this print" in text
  183. assert "AMS0-T0" in text
  184. async def test_the_slot_the_print_used_is_still_charged(self, caplog, monkeypatch):
  185. monkeypatch.setattr(
  186. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  187. _fake_resolver(42),
  188. )
  189. client, written, _ = await _run(
  190. caplog,
  191. tray_remain_start=_slot(60),
  192. current_lookup=_slot(50),
  193. print_used_keys={(0, 0)},
  194. )
  195. assert written == 1
  196. assert client.used == [(42, 100.0)]
  197. async def test_without_evidence_every_slot_is_still_considered(self, caplog, monkeypatch):
  198. """The reporter's own prints have no mapping and no tray changes. The
  199. guard must not turn "we don't know" into "charge nothing"."""
  200. monkeypatch.setattr(
  201. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  202. _fake_resolver(42),
  203. )
  204. client, written, _ = await _run(
  205. caplog,
  206. tray_remain_start=_slot(60),
  207. current_lookup=_slot(50),
  208. print_used_keys=set(),
  209. )
  210. assert written == 1
  211. assert client.used == [(42, 100.0)]