test_archive_cost_estimate_3261.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  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. # An earlier run of the archive, ended; a run still printing is open
  56. # and counts as none.
  57. run_status="completed" if with_run else None,
  58. file_path="archive/print.3mf",
  59. filament_used_grams=grams,
  60. filament_type="PLA",
  61. )
  62. await db_session.commit()
  63. return printer, archive
  64. async def _assign_spool(db_session, printer_id: int, ams_id: int, tray_id: int, cost_per_kg: float | None):
  65. spool = Spool(material="PLA", cost_per_kg=cost_per_kg)
  66. db_session.add(spool)
  67. await db_session.flush()
  68. db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer_id, ams_id=ams_id, tray_id=tray_id))
  69. await db_session.commit()
  70. class TestInternalInventory:
  71. @pytest.mark.asyncio
  72. async def test_priced_from_the_assigned_spool(self, db_session, printer_factory, archive_factory, three_mf, ws):
  73. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  74. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
  75. three_mf.append({"slot_id": 1, "used_g": 4.17, "color": "#FFFFFF"})
  76. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
  77. await db_session.refresh(archive)
  78. assert archive.cost == 7.80
  79. ws.assert_awaited_once_with({"id": archive.id, "cost": 7.80})
  80. @pytest.mark.asyncio
  81. async def test_unpriced_grams_at_the_default_rate(self, db_session, printer_factory, archive_factory, three_mf, ws):
  82. """Slot 2 maps to a tray with no spool; its grams, and grams the 3MF
  83. didn't attribute, are covered at the default rate like completion does."""
  84. printer, archive = await _setup(db_session, printer_factory, archive_factory, grams=12.0)
  85. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1000)
  86. three_mf.extend([{"slot_id": 1, "used_g": 5.0}, {"slot_id": 2, "used_g": 5.0}])
  87. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0, 1])
  88. await db_session.refresh(archive)
  89. # 5 g at 1.0/g + 7 g at 3.4/g
  90. assert archive.cost == 28.80
  91. @pytest.mark.asyncio
  92. async def test_spool_without_price_uses_the_default_rate(
  93. self, db_session, printer_factory, archive_factory, three_mf, ws
  94. ):
  95. printer, archive = await _setup(db_session, printer_factory, archive_factory, grams=10.0)
  96. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=None)
  97. three_mf.append({"slot_id": 1, "used_g": 10.0})
  98. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
  99. await db_session.refresh(archive)
  100. assert archive.cost == 34.00
  101. @pytest.mark.asyncio
  102. async def test_queue_item_mapping_is_used(self, db_session, printer_factory, archive_factory, three_mf, ws):
  103. from backend.app.models.print_queue import PrintQueueItem
  104. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  105. await _assign_spool(db_session, printer.id, 0, 2, cost_per_kg=1870)
  106. db_session.add(
  107. PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", ams_mapping="[2]")
  108. )
  109. await db_session.commit()
  110. three_mf.append({"slot_id": 1, "used_g": 4.17})
  111. trays = [{"id": i, "tray_type": "PLA", "tray_color": "FFFFFFFF"} for i in range(4)]
  112. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(trays))
  113. await db_session.refresh(archive)
  114. assert archive.cost == 7.80
  115. @pytest.mark.asyncio
  116. async def test_external_spool_mapped_as_minus_one(self, db_session, printer_factory, archive_factory, three_mf, ws):
  117. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  118. await _assign_spool(db_session, printer.id, 255, 0, cost_per_kg=1870)
  119. three_mf.append({"slot_id": 1, "used_g": 4.17})
  120. pm = _printer_manager(vt_tray=[{"id": 254, "tray_type": "PLA"}])
  121. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, pm, ams_mapping=[-1])
  122. await db_session.refresh(archive)
  123. assert archive.cost == 7.80
  124. class TestLeavesThePlaceholder:
  125. @pytest.mark.asyncio
  126. async def test_reprint_keeps_the_first_runs_cost(self, db_session, printer_factory, archive_factory, three_mf, ws):
  127. printer, archive = await _setup(db_session, printer_factory, archive_factory, with_run=True)
  128. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
  129. three_mf.append({"slot_id": 1, "used_g": 4.17})
  130. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
  131. await db_session.refresh(archive)
  132. assert archive.cost == 14.18
  133. ws.assert_not_awaited()
  134. @pytest.mark.asyncio
  135. async def test_no_trustworthy_mapping(self, db_session, printer_factory, archive_factory, three_mf, ws):
  136. """No print-command or queue mapping, and the colours don't match a tray:
  137. no positional guess, the placeholder stays."""
  138. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  139. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
  140. three_mf.append({"slot_id": 1, "used_g": 4.17, "color": "#123456"})
  141. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager())
  142. await db_session.refresh(archive)
  143. assert archive.cost == 14.18
  144. @pytest.mark.asyncio
  145. async def test_colour_match_when_no_mapping(self, db_session, printer_factory, archive_factory, three_mf, ws):
  146. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  147. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
  148. three_mf.append({"slot_id": 1, "used_g": 4.17, "color": "#FFFFFF", "type": "PLA"})
  149. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager())
  150. await db_session.refresh(archive)
  151. assert archive.cost == 7.80
  152. @pytest.mark.asyncio
  153. async def test_no_spool_in_any_mapped_tray(self, db_session, printer_factory, archive_factory, three_mf, ws):
  154. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  155. three_mf.append({"slot_id": 1, "used_g": 4.17})
  156. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
  157. await db_session.refresh(archive)
  158. assert archive.cost == 14.18
  159. @pytest.mark.asyncio
  160. async def test_missing_3mf(self, db_session, printer_factory, archive_factory, three_mf, ws, tmp_path):
  161. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  162. await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
  163. (tmp_path / "archive" / "print.3mf").unlink()
  164. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
  165. await db_session.refresh(archive)
  166. assert archive.cost == 14.18
  167. @pytest.mark.asyncio
  168. async def test_an_error_never_escapes(
  169. self, db_session, printer_factory, archive_factory, three_mf, ws, monkeypatch
  170. ):
  171. printer, archive = await _setup(db_session, printer_factory, archive_factory)
  172. three_mf.append({"slot_id": 1, "used_g": 4.17})
  173. def boom(_pid):
  174. raise RuntimeError("printer state unavailable")
  175. pm = SimpleNamespace(get_status=boom)
  176. await estimate_archive_cost_at_start(db_session, printer.id, archive.id, pm, ams_mapping=[0])
  177. await db_session.refresh(archive)
  178. assert archive.cost == 14.18
  179. class TestSpoolman:
  180. @staticmethod
  181. def _client(spools_by_tag: dict | None = None, spools_by_id: dict | None = None):
  182. client = SimpleNamespace()
  183. client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: (spools_by_tag or {}).get(tag))
  184. client.get_spool = AsyncMock(side_effect=lambda sid: (spools_by_id or {})[sid])
  185. return client
  186. @pytest.mark.asyncio
  187. async def test_priced_from_the_slot_assigned_spool(
  188. self, db_session, printer_factory, archive_factory, three_mf, ws
  189. ):
  190. printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True)
  191. three_mf.append({"slot_id": 1, "used_g": 4.17})
  192. spool = {"id": 7, "price": 1870, "initial_weight": 1000, "filament": {"weight": 1000}}
  193. client = self._client(spools_by_id={7: spool})
  194. with (
  195. patch(
  196. "backend.app.services.spoolman.get_spoolman_client",
  197. new=AsyncMock(return_value=client),
  198. ),
  199. patch("backend.app.services.spoolman_tracking._get_printer_serial", new=AsyncMock(return_value="")),
  200. patch(
  201. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  202. new=AsyncMock(return_value=7),
  203. ),
  204. ):
  205. await estimate_archive_cost_at_start(
  206. db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0]
  207. )
  208. await db_session.refresh(archive)
  209. assert archive.cost == 7.80
  210. @pytest.mark.asyncio
  211. async def test_spoolman_unreachable_keeps_the_placeholder(
  212. self, db_session, printer_factory, archive_factory, three_mf, ws
  213. ):
  214. printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True)
  215. three_mf.append({"slot_id": 1, "used_g": 4.17})
  216. with patch(
  217. "backend.app.services.spoolman.get_spoolman_client",
  218. new=AsyncMock(return_value=None),
  219. ):
  220. await estimate_archive_cost_at_start(
  221. db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0]
  222. )
  223. await db_session.refresh(archive)
  224. assert archive.cost == 14.18
  225. @pytest.mark.asyncio
  226. async def test_a_failing_spool_fetch_leaves_only_that_slot_unpriced(
  227. self, db_session, printer_factory, archive_factory, three_mf, ws
  228. ):
  229. printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True, grams=10.0)
  230. three_mf.extend([{"slot_id": 1, "used_g": 5.0}, {"slot_id": 2, "used_g": 5.0}])
  231. client = self._client(spools_by_id={7: {"id": 7, "price": 1000, "initial_weight": 1000}})
  232. trays = [{"id": i, "tray_type": "PLA"} for i in range(2)]
  233. async def slot_assignment(_pid, _ams, tray_id):
  234. return 7 if tray_id == 0 else 8 # spool 8 isn't in Spoolman: get_spool raises
  235. with (
  236. patch(
  237. "backend.app.services.spoolman.get_spoolman_client",
  238. new=AsyncMock(return_value=client),
  239. ),
  240. patch("backend.app.services.spoolman_tracking._get_printer_serial", new=AsyncMock(return_value="")),
  241. patch(
  242. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  243. new=slot_assignment,
  244. ),
  245. ):
  246. await estimate_archive_cost_at_start(
  247. db_session, printer.id, archive.id, _printer_manager(trays), ams_mapping=[0, 1]
  248. )
  249. await db_session.refresh(archive)
  250. # 5 g at 1.0/g + 5 g at the 3.4/g default
  251. assert archive.cost == 22.00
  252. class TestScheduling:
  253. @pytest.mark.asyncio
  254. async def test_runs_in_the_background_on_its_own_session(self):
  255. """Print start doesn't wait for the estimate; it gets its own session and
  256. a copy of the mapping, so later changes to the caller's list don't leak in."""
  257. from contextlib import asynccontextmanager
  258. sentinel_db = object()
  259. @asynccontextmanager
  260. async def fake_session():
  261. yield sentinel_db
  262. mapping = [0, 1]
  263. with (
  264. patch("backend.app.core.database.async_session", new=fake_session),
  265. patch.object(archive_cost_estimate, "estimate_archive_cost_at_start", new=AsyncMock()) as estimate,
  266. ):
  267. task = archive_cost_estimate.schedule_archive_cost_estimate(3, 42, "pm", ams_mapping=mapping, plate_id=2)
  268. mapping.append(9)
  269. await task
  270. estimate.assert_awaited_once_with(sentinel_db, 3, 42, "pm", [0, 1], 2)
  271. class TestRaceWithCompletion:
  272. @pytest.mark.asyncio
  273. async def test_print_that_ended_during_the_lookup_keeps_the_final_cost(
  274. self, db_session, printer_factory, archive_factory, three_mf, ws
  275. ):
  276. """A print that fails seconds in can complete while Spoolman is still
  277. answering; the figure completion wrote must survive."""
  278. printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True)
  279. three_mf.append({"slot_id": 1, "used_g": 4.17})
  280. archive_id = archive.id
  281. async def slow_spool(_sid):
  282. # Completion lands while the estimate waits on Spoolman.
  283. from sqlalchemy import update
  284. from backend.app.models.archive import PrintArchive
  285. await db_session.execute(
  286. update(PrintArchive).where(PrintArchive.id == archive_id).values(status="failed", cost=1.23)
  287. )
  288. await db_session.commit()
  289. return {"id": 7, "price": 1870, "initial_weight": 1000}
  290. client = SimpleNamespace(find_spool_by_tag=AsyncMock(return_value=None), get_spool=slow_spool)
  291. with (
  292. patch("backend.app.services.spoolman.get_spoolman_client", new=AsyncMock(return_value=client)),
  293. patch("backend.app.services.spoolman_tracking._get_printer_serial", new=AsyncMock(return_value="")),
  294. patch(
  295. "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
  296. new=AsyncMock(return_value=7),
  297. ),
  298. ):
  299. await estimate_archive_cost_at_start(
  300. db_session, printer.id, archive_id, _printer_manager(), ams_mapping=[0]
  301. )
  302. await db_session.refresh(archive)
  303. assert archive.cost == 1.23
  304. ws.assert_not_awaited()
  305. class TestCompletionStopsTheEstimate:
  306. @pytest.mark.asyncio
  307. async def test_settling_cancels_a_running_estimate_before_it_writes(self):
  308. """COST-4: completion commits the real cost before it marks the archive
  309. ended, so an estimate still waiting on Spoolman saw "printing" and wrote
  310. over it. Completion now stops the estimate first."""
  311. import asyncio
  312. from contextlib import asynccontextmanager
  313. wrote: list[int] = []
  314. blocked = asyncio.Event()
  315. async def slow_estimate(*_args):
  316. await blocked.wait()
  317. wrote.append(1)
  318. @asynccontextmanager
  319. async def fake_session():
  320. yield object()
  321. with (
  322. patch("backend.app.core.database.async_session", new=fake_session),
  323. patch.object(archive_cost_estimate, "estimate_archive_cost_at_start", new=slow_estimate),
  324. ):
  325. task = archive_cost_estimate.schedule_archive_cost_estimate(3, 42, "pm")
  326. await asyncio.sleep(0)
  327. await archive_cost_estimate.settle_archive_cost_estimate(42)
  328. blocked.set()
  329. await asyncio.sleep(0)
  330. assert task.cancelled()
  331. assert wrote == []
  332. @pytest.mark.asyncio
  333. async def test_settling_with_nothing_running_is_harmless(self):
  334. await archive_cost_estimate.settle_archive_cost_estimate(9999)