test_spoolman_print_cost_2591.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. """Print cost comes from the linked Spoolman spool's price (#2591).
  2. Spoolman exists to hold per-spool pricing, and #261 gave that as the reason for
  3. integrating with it. Bambuddy never read it. ``archive.py`` prices a print once,
  4. at archive time, from the built-in Filament catalogue matched on the primary
  5. type and falling back to the global default rate -- and in Spoolman mode nothing
  6. revisited that figure, because the per-spool recompute in
  7. ``usage_tracker.on_print_complete`` runs only over rows the built-in inventory
  8. writes, and ``spoolman_owns_usage`` stops it writing any.
  9. The reporter's install had an empty catalogue (``filaments_total: 0``), so every
  10. print was priced at the global default no matter what the spool cost.
  11. """
  12. from types import SimpleNamespace
  13. from unittest.mock import AsyncMock, MagicMock, patch
  14. import pytest
  15. from backend.app.services.spoolman_tracking import _PrintCost, _spool_cost_per_gram
  16. class _AsyncCtx:
  17. def __init__(self, db):
  18. self._db = db
  19. async def __aenter__(self):
  20. return self._db
  21. async def __aexit__(self, *exc):
  22. return False
  23. def _spool(spool_id, *, price=None, filament_price=None, weight=1000, color="888888", material="PLA"):
  24. """A Spoolman spool row, shaped as its API returns one."""
  25. return {
  26. "id": spool_id,
  27. "price": price,
  28. "filament": {
  29. "price": filament_price,
  30. "weight": weight,
  31. "color_hex": color,
  32. "material": material,
  33. },
  34. }
  35. class TestSpoolCostPerGram:
  36. def test_uses_the_filament_catalogue_price(self):
  37. """25.00 for a 1 kg spool is 2.5 cents a gram."""
  38. assert _spool_cost_per_gram(_spool(1, filament_price=25.0, weight=1000)) == pytest.approx(0.025)
  39. def test_the_spools_own_price_overrides_the_filaments(self):
  40. """Spoolman carries a price on the spool for the purchase that cost
  41. something other than the catalogue figure. That is the one the Spoolman
  42. UI shows, so it is the one a print should be charged at."""
  43. rate = _spool_cost_per_gram(_spool(1, price=40.0, filament_price=25.0, weight=1000))
  44. assert rate == pytest.approx(0.04)
  45. def test_weight_is_net_filament_grams_not_a_fixed_kilo(self):
  46. """A 750 g spool is not a kilo. Dividing by a constant would under-price
  47. every non-standard roll."""
  48. assert _spool_cost_per_gram(_spool(1, filament_price=30.0, weight=750)) == pytest.approx(0.04)
  49. def test_a_zero_spool_override_falls_through_to_the_catalogue(self):
  50. """Spoolman leaves the spool override null when unset, but importers and
  51. API clients write 0 often enough that reading it as "this roll was free"
  52. would price a whole print at the default rate with a perfectly good
  53. catalogue price one level down."""
  54. rate = _spool_cost_per_gram(_spool(1, price=0, filament_price=25.0, weight=1000))
  55. assert rate == pytest.approx(0.025)
  56. @pytest.mark.parametrize(
  57. "spool",
  58. [
  59. _spool(1, weight=1000), # no price anywhere
  60. _spool(1, filament_price=25.0, weight=None), # no reference weight
  61. _spool(1, filament_price=0, weight=1000), # zero is unpriced, not free
  62. _spool(1, filament_price=-5, weight=1000),
  63. _spool(1, filament_price="abc", weight=1000),
  64. {"id": 1, "price": 25, "filament": "not a dict"},
  65. {"id": 1, "price": 25, "filament": {"weight": True}}, # bool is an int in Python
  66. {"id": 1, "price": float("nan"), "filament": {"weight": 1000}},
  67. {"id": 1, "price": 1e308, "filament": {"weight": 1e-308}}, # quotient overflows
  68. None,
  69. ],
  70. )
  71. def test_says_nothing_rather_than_guessing(self, spool):
  72. """None means "fall back to the default rate", not "this was free" --
  73. and never a NaN or an infinity, which every later comparison would
  74. silently pass through into the archive."""
  75. assert _spool_cost_per_gram(spool) is None
  76. class TestPrintCostAccumulator:
  77. def test_sums_each_slot_at_its_own_rate(self):
  78. """The defect archive.py has: it takes the primary type's rate and
  79. applies it to the whole print's grams, so a slot of expensive filament
  80. is billed at the price of the cheap one beside it."""
  81. cost = _PrintCost()
  82. cost.add(100.0, _spool(1, filament_price=20.0, weight=1000), "slot 1") # 0.02/g
  83. cost.add(50.0, _spool(2, filament_price=60.0, weight=1000), "slot 2") # 0.06/g
  84. assert cost.cost == pytest.approx(2.0 + 3.0)
  85. assert cost.priced_grams == pytest.approx(150.0)
  86. assert cost.priced == 2
  87. def test_an_unpriced_spool_is_counted_but_not_charged(self):
  88. """Its grams stay out of priced_grams so the caller covers them at the
  89. default rate rather than recording them as free."""
  90. cost = _PrintCost()
  91. cost.add(100.0, _spool(1, filament_price=20.0, weight=1000), "slot 1")
  92. cost.add(50.0, _spool(2, weight=1000), "slot 2")
  93. assert cost.cost == pytest.approx(2.0)
  94. assert cost.priced_grams == pytest.approx(100.0)
  95. assert (cost.priced, cost.unpriced) == (1, 1)
  96. def test_zero_grams_is_not_a_slot(self):
  97. cost = _PrintCost()
  98. cost.add(0.0, _spool(1, filament_price=20.0, weight=1000), "slot 1")
  99. assert (cost.priced, cost.unpriced, cost.cost) == (0, 0, 0.0)
  100. class TestReportUsagePricesTheArchive:
  101. """End to end: the price has to reach PrintArchive.cost."""
  102. @staticmethod
  103. def _run(tracking, state, spools_by_tag, archive, *, existing_runs=0, default_cost="25"):
  104. rows = iter([tracking])
  105. def _next_row(*_args, **_kwargs):
  106. result = MagicMock()
  107. result.scalar_one_or_none.return_value = next(rows, archive)
  108. # The first-run guard counts PrintLogEntry rows.
  109. result.scalar.return_value = existing_runs
  110. return result
  111. db = AsyncMock()
  112. db.execute = AsyncMock(side_effect=_next_row)
  113. db.delete = AsyncMock()
  114. db.commit = AsyncMock()
  115. client = AsyncMock()
  116. client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: spools_by_tag.get(tag))
  117. client.get_spool = AsyncMock(
  118. side_effect=lambda sid: next((s for s in spools_by_tag.values() if s["id"] == sid), None)
  119. )
  120. client.use_spool = AsyncMock()
  121. pm = MagicMock()
  122. pm.get_status.return_value = state
  123. async def _get_setting(_db, key):
  124. return {"spoolman_enabled": "true", "default_filament_cost": default_cost}.get(key)
  125. async def _go():
  126. from backend.app.services.spoolman_tracking import report_usage
  127. with (
  128. patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
  129. patch("backend.app.api.routes.settings.get_setting", AsyncMock(side_effect=_get_setting)),
  130. patch(
  131. "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
  132. AsyncMock(return_value=client),
  133. ),
  134. patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SER")),
  135. patch(
  136. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  137. AsyncMock(return_value=None),
  138. ),
  139. patch("backend.app.services.printer_manager.printer_manager", pm),
  140. ):
  141. await report_usage(printer_id=1, archive_id=7)
  142. return _go, client
  143. @staticmethod
  144. def _state():
  145. return SimpleNamespace(
  146. raw_data={},
  147. tray_change_log=[],
  148. total_layers=0,
  149. layer_num=0,
  150. tray_now=255,
  151. last_loaded_tray=-1,
  152. )
  153. @pytest.mark.asyncio
  154. async def test_the_linked_spools_price_replaces_the_default(self):
  155. """The reported bug. 100 g off a spool that cost 40.00 for 1 kg is 4.00,
  156. not the 2.50 the global 25/kg default produced."""
  157. tracking = SimpleNamespace(
  158. filament_usage=[{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": "#888888"}],
  159. ams_trays={"0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA"}},
  160. slot_to_tray=[0],
  161. tray_remain_start=None,
  162. layer_usage=None,
  163. filament_properties=None,
  164. tray_now_at_start=0,
  165. )
  166. archive = SimpleNamespace(filament_color="#888888", filament_type="PLA", filament_used_grams=100.0, cost=2.5)
  167. run, client = self._run(tracking, self._state(), {"TRAY0": _spool(41, price=40.0, weight=1000)}, archive)
  168. await run()
  169. client.use_spool.assert_awaited_once_with(41, 100.0)
  170. assert archive.cost == pytest.approx(4.0)
  171. @pytest.mark.asyncio
  172. async def test_multi_material_bills_each_slot_at_its_own_price(self):
  173. """archive.py charged the whole print at the primary type's rate. Two
  174. slots, 100 g at 0.02/g and 50 g at 0.06/g, is 5.00 -- not 150 g at
  175. either one of them (3.00 or 9.00)."""
  176. tracking = SimpleNamespace(
  177. filament_usage=[
  178. {"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": "#888888"},
  179. {"slot_id": 2, "used_g": 50.0, "type": "PA", "color": "#111111"},
  180. ],
  181. ams_trays={
  182. "0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA"},
  183. "1": {"tray_uuid": "TRAY1", "tag_uid": "", "tray_type": "PA"},
  184. },
  185. slot_to_tray=[0, 1],
  186. tray_remain_start=None,
  187. layer_usage=None,
  188. filament_properties=None,
  189. tray_now_at_start=0,
  190. )
  191. archive = SimpleNamespace(
  192. filament_color="#888888", filament_type="PLA,PA", filament_used_grams=150.0, cost=3.75
  193. )
  194. run, _client = self._run(
  195. tracking,
  196. self._state(),
  197. {
  198. "TRAY0": _spool(41, filament_price=20.0, weight=1000),
  199. "TRAY1": _spool(42, filament_price=60.0, weight=1000, material="PA", color="111111"),
  200. },
  201. archive,
  202. )
  203. await run()
  204. assert archive.cost == pytest.approx(5.0)
  205. @pytest.mark.asyncio
  206. async def test_grams_no_spool_could_price_fall_back_to_the_default_rate(self):
  207. """One priced slot out of a heavier print must not report only its own
  208. share -- that is #1344 in the other inventory mode. 100 g priced at
  209. 0.04/g plus 50 g the archive knows about but nothing priced, at the
  210. 25/kg default, is 4.00 + 1.25."""
  211. tracking = SimpleNamespace(
  212. filament_usage=[{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": "#888888"}],
  213. ams_trays={"0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA"}},
  214. slot_to_tray=[0],
  215. tray_remain_start=None,
  216. layer_usage=None,
  217. filament_properties=None,
  218. tray_now_at_start=0,
  219. )
  220. archive = SimpleNamespace(filament_color="#888888", filament_type="PLA", filament_used_grams=150.0, cost=3.75)
  221. run, _client = self._run(tracking, self._state(), {"TRAY0": _spool(41, price=40.0, weight=1000)}, archive)
  222. await run()
  223. assert archive.cost == pytest.approx(5.25)
  224. @pytest.mark.asyncio
  225. async def test_a_spool_with_no_price_leaves_the_archive_alone(self):
  226. """Nothing better is known than what archive.py already recorded, so
  227. an install with prices in neither place stays exactly where it was."""
  228. tracking = SimpleNamespace(
  229. filament_usage=[{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": "#888888"}],
  230. ams_trays={"0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA"}},
  231. slot_to_tray=[0],
  232. tray_remain_start=None,
  233. layer_usage=None,
  234. filament_properties=None,
  235. tray_now_at_start=0,
  236. )
  237. archive = SimpleNamespace(filament_color="#888888", filament_type="PLA", filament_used_grams=100.0, cost=2.5)
  238. run, _client = self._run(tracking, self._state(), {"TRAY0": _spool(41, weight=1000)}, archive)
  239. await run()
  240. assert archive.cost == pytest.approx(2.5)
  241. @pytest.mark.asyncio
  242. async def test_a_reprint_does_not_overwrite_the_first_runs_cost(self):
  243. """Same guard the built-in writer carries (#1378): reprint actuals live
  244. in PrintLogEntry, and the archive card keeps the first run's figure."""
  245. tracking = SimpleNamespace(
  246. filament_usage=[{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#888888"}],
  247. ams_trays={"0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA"}},
  248. slot_to_tray=[0],
  249. tray_remain_start=None,
  250. layer_usage=None,
  251. filament_properties=None,
  252. tray_now_at_start=0,
  253. )
  254. archive = SimpleNamespace(filament_color="#888888", filament_type="PLA", filament_used_grams=100.0, cost=4.0)
  255. run, client = self._run(
  256. tracking, self._state(), {"TRAY0": _spool(41, price=40.0, weight=1000)}, archive, existing_runs=1
  257. )
  258. await run()
  259. # The spool is still charged for the reprint's grams...
  260. client.use_spool.assert_awaited_once_with(41, 10.0)
  261. # ...but the archive keeps the first run's number.
  262. assert archive.cost == pytest.approx(4.0)