test_archive_cost_estimate_3261.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. """A running print is priced from its spools at start, not only at completion (#3261).
  2. Before this, the archive card showed ``grams x default_filament_cost`` (or the
  3. catalogue rate) for the whole print and swapped in the spool-based figure only
  4. when the print finished. The estimate uses the same rules as the completion
  5. writers: each slot at the spool in its mapped tray, everything else at the
  6. default rate, and a reprint keeps its first run's cost.
  7. """
  8. from types import SimpleNamespace
  9. from unittest.mock import AsyncMock, patch
  10. import pytest
  11. from backend.app.models.settings import Settings
  12. from backend.app.models.spool import Spool
  13. from backend.app.models.spool_assignment import SpoolAssignment
  14. from backend.app.services import archive_cost_estimate
  15. from backend.app.services.archive_cost_estimate import estimate_archive_cost_at_start
  16. # Reporter's archive 295: 4.17 g of PLA; their default rate is 3,400/kg and the
  17. # linked spool cost 1,870 for 1,000 g.
  18. DEFAULT_PER_KG = "3400"
  19. def _printer_manager(trays: list[dict] | None = None, vt_tray: list[dict] | None = None):
  20. raw = {
  21. "ams": [
  22. {"id": 0, "tray": trays if trays is not None else [{"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF"}]}
  23. ],
  24. "vt_tray": vt_tray or [],
  25. }
  26. state = SimpleNamespace(raw_data=raw)
  27. return SimpleNamespace(get_status=lambda _pid: state)
  28. @pytest.fixture
  29. def three_mf(tmp_path, monkeypatch):
  30. """Point base_dir at tmp_path, drop a 3MF there, and stub its per-slot usage."""
  31. monkeypatch.setattr(archive_cost_estimate.app_settings, "base_dir", tmp_path)
  32. (tmp_path / "archive").mkdir()
  33. (tmp_path / "archive" / "print.3mf").write_bytes(b"3mf")
  34. usage: list[dict] = []
  35. monkeypatch.setattr(
  36. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  37. lambda _path, _plate: usage,
  38. )
  39. return usage
  40. @pytest.fixture
  41. def ws():
  42. with patch("backend.app.core.websocket.ws_manager.send_archive_updated", new=AsyncMock()) as sent:
  43. yield sent
  44. async def _setup(db_session, printer_factory, archive_factory, *, spoolman=False, with_run=False, grams=4.17):
  45. db_session.add(Settings(key="default_filament_cost", value=DEFAULT_PER_KG))
  46. if spoolman:
  47. db_session.add(Settings(key="spoolman_enabled", value="true"))
  48. printer = await printer_factory()
  49. archive = await archive_factory(
  50. printer.id,
  51. print_name="Archive295",
  52. status="printing",
  53. cost=14.18,
  54. with_run=with_run,
  55. file_path="archive/print.3mf",
  56. filament_used_grams=grams,
  57. filament_type="PLA",
  58. )
  59. await db_session.commit()
  60. return printer, archive
  61. async def _assign_spool(db_session, printer_id: int, ams_id: int, tray_id: int, cost_per_kg: float | None):
  62. spool = Spool(material="PLA", cost_per_kg=cost_per_kg)
  63. db_session.add(spool)
  64. await db_session.flush()
  65. db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer_id, ams_id=ams_id, tray_id=tray_id))
  66. await db_session.commit()
  67. class TestInternalInventory:
  68. @pytest.mark.asyncio
  69. async def test_priced_from_the_assigned_spool(self, db_session, printer_factory, archive_factory, three_mf, ws):
  70. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  71. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
  72. three_mf.append({"slot_id": 1, "used_g": 4.17, "color": "#FFFFFF"})
  73. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
  74. await db_session.refresh(archive)
  75. assert archive.cost == 7.80
  76. ws.assert_awaited_once_with({"id": archive.id, "cost": 7.80})
  77. @pytest.mark.asyncio
  78. async def test_unpriced_grams_at_the_default_rate(self, db_session, printer_factory, archive_factory, three_mf, ws):
  79. """Slot 2 maps to a tray with no spool; its grams, and grams the 3MF
  80. didn't attribute, are covered at the default rate like completion does."""
  81. printer, archive = await _setup(db_session, printer_factory, archive_factory, grams=12.0)
  82. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1000)
  83. three_mf.extend([{"slot_id": 1, "used_g": 5.0}, {"slot_id": 2, "used_g": 5.0}])
  84. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0, 1])
  85. await db_session.refresh(archive)
  86. # 5 g at 1.0/g + 7 g at 3.4/g
  87. assert archive.cost == 28.80
  88. @pytest.mark.asyncio
  89. async def test_spool_without_price_uses_the_default_rate(
  90. self, db_session, printer_factory, archive_factory, three_mf, ws
  91. ):
  92. printer, archive = await _setup(db_session, printer_factory, archive_factory, grams=10.0)
  93. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=None)
  94. three_mf.append({"slot_id": 1, "used_g": 10.0})
  95. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
  96. await db_session.refresh(archive)
  97. assert archive.cost == 34.00
  98. @pytest.mark.asyncio
  99. async def test_queue_item_mapping_is_used(self, db_session, printer_factory, archive_factory, three_mf, ws):
  100. from backend.app.models.print_queue import PrintQueueItem
  101. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  102. await _assign_spool(db_session, printer.id, 0, 2, cost_per_kg=1870)
  103. db_session.add(
  104. PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", ams_mapping="[2]")
  105. )
  106. await db_session.commit()
  107. three_mf.append({"slot_id": 1, "used_g": 4.17})
  108. trays = [{"id": i, "tray_type": "PLA", "tray_color": "FFFFFFFF"} for i in range(4)]
  109. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(trays))
  110. await db_session.refresh(archive)
  111. assert archive.cost == 7.80
  112. @pytest.mark.asyncio
  113. async def test_external_spool_mapped_as_minus_one(self, db_session, printer_factory, archive_factory, three_mf, ws):
  114. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  115. await _assign_spool(db_session, printer.id, 255, 0, cost_per_kg=1870)
  116. three_mf.append({"slot_id": 1, "used_g": 4.17})
  117. pm = _printer_manager(vt_tray=[{"id": 254, "tray_type": "PLA"}])
  118. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, pm, ams_mapping=[-1])
  119. await db_session.refresh(archive)
  120. assert archive.cost == 7.80
  121. class TestLeavesThePlaceholder:
  122. @pytest.mark.asyncio
  123. async def test_reprint_keeps_the_first_runs_cost(self, db_session, printer_factory, archive_factory, three_mf, ws):
  124. printer, archive = await _setup(db_session, printer_factory, archive_factory, with_run=True)
  125. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
  126. three_mf.append({"slot_id": 1, "used_g": 4.17})
  127. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
  128. await db_session.refresh(archive)
  129. assert archive.cost == 14.18
  130. ws.assert_not_awaited()
  131. @pytest.mark.asyncio
  132. async def test_no_trustworthy_mapping(self, db_session, printer_factory, archive_factory, three_mf, ws):
  133. """No print-command or queue mapping, and the colours don't match a tray:
  134. no positional guess, the placeholder stays."""
  135. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  136. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
  137. three_mf.append({"slot_id": 1, "used_g": 4.17, "color": "#123456"})
  138. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager())
  139. await db_session.refresh(archive)
  140. assert archive.cost == 14.18
  141. @pytest.mark.asyncio
  142. async def test_colour_match_when_no_mapping(self, db_session, printer_factory, archive_factory, three_mf, ws):
  143. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  144. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
  145. three_mf.append({"slot_id": 1, "used_g": 4.17, "color": "#FFFFFF", "type": "PLA"})
  146. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager())
  147. await db_session.refresh(archive)
  148. assert archive.cost == 7.80
  149. @pytest.mark.asyncio
  150. async def test_no_spool_in_any_mapped_tray(self, db_session, printer_factory, archive_factory, three_mf, ws):
  151. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  152. three_mf.append({"slot_id": 1, "used_g": 4.17})
  153. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
  154. await db_session.refresh(archive)
  155. assert archive.cost == 14.18
  156. @pytest.mark.asyncio
  157. async def test_missing_3mf(self, db_session, printer_factory, archive_factory, three_mf, ws, tmp_path):
  158. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  159. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
  160. (tmp_path / "archive" / "print.3mf").unlink()
  161. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
  162. await db_session.refresh(archive)
  163. assert archive.cost == 14.18
  164. @pytest.mark.asyncio
  165. async def test_an_error_never_escapes(
  166. self, db_session, printer_factory, archive_factory, three_mf, ws, monkeypatch
  167. ):
  168. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  169. three_mf.append({"slot_id": 1, "used_g": 4.17})
  170. def boom(_pid):
  171. raise RuntimeError("printer state unavailable")
  172. pm = SimpleNamespace(get_status=boom)
  173. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, pm, ams_mapping=[0])
  174. await db_session.refresh(archive)
  175. assert archive.cost == 14.18
  176. class TestSpoolman:
  177. @staticmethod
  178. def _client(spools_by_tag: dict | None = None, spools_by_id: dict | None = None):
  179. client = SimpleNamespace()
  180. client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: (spools_by_tag or {}).get(tag))
  181. client.get_spool = AsyncMock(side_effect=lambda sid: (spools_by_id or {})[sid])
  182. return client
  183. @pytest.mark.asyncio
  184. async def test_priced_from_the_slot_assigned_spool(
  185. self, db_session, printer_factory, archive_factory, three_mf, ws
  186. ):
  187. printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True)
  188. three_mf.append({"slot_id": 1, "used_g": 4.17})
  189. spool = {"id": 7, "price": 1870, "initial_weight": 1000, "filament": {"weight": 1000}}
  190. client = self._client(spools_by_id={7: spool})
  191. with (
  192. patch(
  193. "backend.app.services.spoolman.get_spoolman_client",
  194. new=AsyncMock(return_value=client),
  195. ),
  196. patch("backend.app.services.spoolman_tracking._get_printer_serial", new=AsyncMock(return_value="")),
  197. patch(
  198. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  199. new=AsyncMock(return_value=7),
  200. ),
  201. ):
  202. await estimate_archive_cost_at_start(
  203. db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0]
  204. )
  205. await db_session.refresh(archive)
  206. assert archive.cost == 7.80
  207. @pytest.mark.asyncio
  208. async def test_spoolman_unreachable_keeps_the_placeholder(
  209. self, db_session, printer_factory, archive_factory, three_mf, ws
  210. ):
  211. printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True)
  212. three_mf.append({"slot_id": 1, "used_g": 4.17})
  213. with patch(
  214. "backend.app.services.spoolman.get_spoolman_client",
  215. new=AsyncMock(return_value=None),
  216. ):
  217. await estimate_archive_cost_at_start(
  218. db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0]
  219. )
  220. await db_session.refresh(archive)
  221. assert archive.cost == 14.18
  222. @pytest.mark.asyncio
  223. async def test_a_failing_spool_fetch_leaves_only_that_slot_unpriced(
  224. self, db_session, printer_factory, archive_factory, three_mf, ws
  225. ):
  226. printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True, grams=10.0)
  227. three_mf.extend([{"slot_id": 1, "used_g": 5.0}, {"slot_id": 2, "used_g": 5.0}])
  228. client = self._client(spools_by_id={7: {"id": 7, "price": 1000, "initial_weight": 1000}})
  229. trays = [{"id": i, "tray_type": "PLA"} for i in range(2)]
  230. async def slot_assignment(_pid, _ams, tray_id):
  231. return 7 if tray_id == 0 else 8 # spool 8 isn't in Spoolman: get_spool raises
  232. with (
  233. patch(
  234. "backend.app.services.spoolman.get_spoolman_client",
  235. new=AsyncMock(return_value=client),
  236. ),
  237. patch("backend.app.services.spoolman_tracking._get_printer_serial", new=AsyncMock(return_value="")),
  238. patch(
  239. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  240. new=slot_assignment,
  241. ),
  242. ):
  243. await estimate_archive_cost_at_start(
  244. db_session, printer.id, archive.id, _printer_manager(trays), ams_mapping=[0, 1]
  245. )
  246. await db_session.refresh(archive)
  247. # 5 g at 1.0/g + 5 g at the 3.4/g default
  248. assert archive.cost == 22.00
  249. class TestScheduling:
  250. @pytest.mark.asyncio
  251. async def test_runs_in_the_background_on_its_own_session(self):
  252. """Print start doesn't wait for the estimate; it gets its own session and
  253. a copy of the mapping, so later changes to the caller's list don't leak in."""
  254. from contextlib import asynccontextmanager
  255. sentinel_db = object()
  256. @asynccontextmanager
  257. async def fake_session():
  258. yield sentinel_db
  259. mapping = [0, 1]
  260. with (
  261. patch("backend.app.core.database.async_session", new=fake_session),
  262. patch.object(archive_cost_estimate, "estimate_archive_cost_at_start", new=AsyncMock()) as estimate,
  263. ):
  264. task = archive_cost_estimate.schedule_archive_cost_estimate(3, 42, "pm", ams_mapping=mapping, plate_id=2)
  265. mapping.append(9)
  266. await task
  267. estimate.assert_awaited_once_with(sentinel_db, 3, 42, "pm", [0, 1], 2)
  268. class TestRaceWithCompletion:
  269. @pytest.mark.asyncio
  270. async def test_print_that_ended_during_the_lookup_keeps_the_final_cost(
  271. self, db_session, printer_factory, archive_factory, three_mf, ws
  272. ):
  273. """A print that fails seconds in can complete while Spoolman is still
  274. answering; the figure completion wrote must survive."""
  275. printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True)
  276. three_mf.append({"slot_id": 1, "used_g": 4.17})
  277. archive_id = archive.id
  278. async def slow_spool(_sid):
  279. # Completion lands while the estimate waits on Spoolman.
  280. from sqlalchemy import update
  281. from backend.app.models.archive import PrintArchive
  282. await db_session.execute(
  283. update(PrintArchive).where(PrintArchive.id == archive_id).values(status="failed", cost=1.23)
  284. )
  285. await db_session.commit()
  286. return {"id": 7, "price": 1870, "initial_weight": 1000}
  287. client = SimpleNamespace(find_spool_by_tag=AsyncMock(return_value=None), get_spool=slow_spool)
  288. with (
  289. patch("backend.app.services.spoolman.get_spoolman_client", new=AsyncMock(return_value=client)),
  290. patch("backend.app.services.spoolman_tracking._get_printer_serial", new=AsyncMock(return_value="")),
  291. patch(
  292. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  293. new=AsyncMock(return_value=7),
  294. ),
  295. ):
  296. await estimate_archive_cost_at_start(
  297. db_session, printer.id, archive_id, _printer_manager(), ams_mapping=[0]
  298. )
  299. await db_session.refresh(archive)
  300. assert archive.cost == 1.23
  301. ws.assert_not_awaited()