test_cost_tracking.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139
  1. """Unit tests for cost tracking in usage_tracker.py.
  2. Tests cost calculation scenarios:
  3. - Spool-specific cost_per_kg
  4. - Default fallback cost from settings
  5. - Spools without cost (None)
  6. - Completed prints
  7. - Failed/partial prints
  8. - Cost aggregation to archives
  9. """
  10. import os
  11. import tempfile
  12. from datetime import datetime, timezone
  13. from types import SimpleNamespace
  14. from unittest.mock import AsyncMock, MagicMock, patch
  15. import pytest
  16. from backend.app.services.usage_tracker import (
  17. PrintSession,
  18. _active_sessions,
  19. _track_from_3mf,
  20. on_print_complete,
  21. )
  22. def _make_spool(spool_id=1, label_weight=1000, weight_used=0, cost_per_kg=None):
  23. """Create a mock Spool object with cost fields."""
  24. spool = MagicMock()
  25. spool.id = spool_id
  26. spool.label_weight = label_weight
  27. spool.weight_used = weight_used
  28. spool.cost_per_kg = cost_per_kg
  29. spool.last_used = None
  30. spool.material = "PLA"
  31. return spool
  32. def _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0):
  33. """Create a mock SpoolAssignment object."""
  34. assignment = MagicMock()
  35. assignment.spool_id = spool_id
  36. assignment.printer_id = printer_id
  37. assignment.ams_id = ams_id
  38. assignment.tray_id = tray_id
  39. return assignment
  40. def _make_archive(archive_id=1, file_path=None):
  41. """Create a mock PrintArchive object with a temp file, and register cleanup."""
  42. if file_path is None:
  43. with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf", prefix="test_print_") as tmp:
  44. file_path = tmp.name
  45. # Register cleanup for this file after the test
  46. import pytest
  47. frame = None
  48. try:
  49. raise Exception
  50. except Exception:
  51. import sys
  52. frame = sys._getframe(1)
  53. request = frame.f_locals.get("request")
  54. if request is not None:
  55. def cleanup():
  56. try:
  57. os.remove(file_path)
  58. except Exception:
  59. pass
  60. request.addfinalizer(cleanup)
  61. archive = MagicMock()
  62. archive.id = archive_id
  63. archive.file_path = file_path
  64. # Explicit numeric default so the #1344 top-up logic (archive_grams -
  65. # tracked_grams) doesn't compare a MagicMock to a float. Tests that
  66. # exercise the top-up path overwrite this with a real number.
  67. archive.filament_used_grams = 0
  68. return archive
  69. @pytest.fixture(autouse=True)
  70. def cleanup_temp_archives():
  71. yield
  72. # Cleanup any temp .3mf files created by _make_archive
  73. import glob
  74. for f in glob.glob("test_print_*.3mf"):
  75. try:
  76. os.remove(f)
  77. except Exception:
  78. pass
  79. @pytest.fixture(autouse=True)
  80. def cleanup_test_print_gcode():
  81. yield
  82. import os
  83. path = "archives/test/test_print.gcode.3mf"
  84. if os.path.exists(path):
  85. try:
  86. os.remove(path)
  87. except Exception:
  88. pass
  89. @pytest.fixture
  90. def archive_factory_temp():
  91. import tempfile
  92. def _factory(*args, **kwargs):
  93. with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf", prefix="test_print_", dir="archives/test") as tmp:
  94. kwargs["file_path"] = tmp.name
  95. return kwargs["file_path"]
  96. yield _factory
  97. # Cleanup
  98. import glob
  99. import os
  100. for f in glob.glob("archives/test/test_print_*.3mf"):
  101. try:
  102. os.remove(f)
  103. except Exception:
  104. pass
  105. def _mock_db_sequential(responses):
  106. """Create mock db that returns responses in order."""
  107. db = AsyncMock()
  108. call_count = [0]
  109. async def mock_execute(*args, **kwargs):
  110. idx = call_count[0]
  111. call_count[0] += 1
  112. result = MagicMock()
  113. if idx < len(responses):
  114. result.scalar_one_or_none.return_value = responses[idx]
  115. else:
  116. result.scalar_one_or_none.return_value = None
  117. return result
  118. db.execute = mock_execute
  119. return db
  120. class TestCostCalculation:
  121. """Tests for cost calculation in usage tracking."""
  122. @pytest.fixture(autouse=True)
  123. def _clear_sessions(self):
  124. _active_sessions.clear()
  125. yield
  126. _active_sessions.clear()
  127. @pytest.mark.asyncio
  128. async def test_cost_with_spool_specific_cost_per_kg(self):
  129. """Cost is calculated using spool-specific cost_per_kg when available."""
  130. # Spool with cost_per_kg = 25.00 USD/kg
  131. spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)
  132. assignment = _make_assignment(spool_id=1)
  133. archive = _make_archive(archive_id=10)
  134. _active_sessions[1] = PrintSession(
  135. printer_id=1,
  136. print_name="Test",
  137. started_at=datetime.now(timezone.utc),
  138. tray_remain_start={(0, 0): 80},
  139. tray_now_at_start=0,
  140. )
  141. printer_manager = MagicMock()
  142. printer_manager.get_status.return_value = SimpleNamespace(
  143. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  144. progress=100,
  145. layer_num=50,
  146. tray_now=0,
  147. )
  148. # db returns: archive, queue_item(None), assignment, spool
  149. db = _mock_db_sequential([archive, None, assignment, spool])
  150. # 20g used from 3MF
  151. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
  152. with (
  153. patch("backend.app.core.config.settings") as mock_settings,
  154. patch("backend.app.api.routes.settings.get_setting", return_value="15.0"), # default cost
  155. patch(
  156. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  157. return_value=filament_usage,
  158. ),
  159. ):
  160. mock_settings.base_dir = MagicMock()
  161. mock_path = MagicMock()
  162. mock_path.exists.return_value = True
  163. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  164. results = await on_print_complete(
  165. printer_id=1,
  166. data={"status": "completed"},
  167. printer_manager=printer_manager,
  168. db=db,
  169. archive_id=10,
  170. )
  171. assert len(results) == 1
  172. assert results[0]["spool_id"] == 1
  173. assert results[0]["weight_used"] == 20.0
  174. # Cost = 20g / 1000 * 25.0 = 0.50
  175. assert results[0]["cost"] == 0.50
  176. @pytest.mark.asyncio
  177. async def test_cost_with_default_fallback(self):
  178. """Cost uses default_filament_cost from settings when spool cost is None."""
  179. # Spool without cost_per_kg
  180. spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)
  181. assignment = _make_assignment(spool_id=1)
  182. archive = _make_archive(archive_id=10)
  183. _active_sessions[1] = PrintSession(
  184. printer_id=1,
  185. print_name="Test",
  186. started_at=datetime.now(timezone.utc),
  187. tray_remain_start={(0, 0): 80},
  188. tray_now_at_start=0,
  189. )
  190. printer_manager = MagicMock()
  191. printer_manager.get_status.return_value = SimpleNamespace(
  192. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  193. progress=100,
  194. layer_num=50,
  195. tray_now=0,
  196. )
  197. # db returns: archive, queue_item(None), assignment, spool
  198. db = _mock_db_sequential([archive, None, assignment, spool])
  199. # 30g used from 3MF
  200. filament_usage = [{"slot_id": 1, "used_g": 30.0, "type": "PLA", "color": "#FF0000"}]
  201. with (
  202. patch("backend.app.core.config.settings") as mock_settings,
  203. patch("backend.app.api.routes.settings.get_setting", return_value="15.0"), # default: 15.0/kg
  204. patch(
  205. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  206. return_value=filament_usage,
  207. ),
  208. ):
  209. mock_settings.base_dir = MagicMock()
  210. mock_path = MagicMock()
  211. mock_path.exists.return_value = True
  212. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  213. results = await on_print_complete(
  214. printer_id=1,
  215. data={"status": "completed"},
  216. printer_manager=printer_manager,
  217. db=db,
  218. archive_id=10,
  219. )
  220. assert len(results) == 1
  221. assert results[0]["spool_id"] == 1
  222. assert results[0]["weight_used"] == 30.0
  223. # Cost = 30g / 1000 * 15.0 = 0.45
  224. assert results[0]["cost"] == 0.45
  225. @pytest.mark.asyncio
  226. async def test_cost_uses_the_settings_default_when_none_is_saved(self):
  227. """Never saved, the default is the 25/kg the Settings page shows, as for
  228. every other reader of it; not 0, which left unpriced spools uncosted."""
  229. # Spool without cost_per_kg
  230. spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)
  231. assignment = _make_assignment(spool_id=1)
  232. archive = _make_archive(archive_id=10)
  233. _active_sessions[1] = PrintSession(
  234. printer_id=1,
  235. print_name="Test",
  236. started_at=datetime.now(timezone.utc),
  237. tray_remain_start={(0, 0): 80},
  238. tray_now_at_start=0,
  239. )
  240. printer_manager = MagicMock()
  241. printer_manager.get_status.return_value = SimpleNamespace(
  242. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  243. progress=100,
  244. layer_num=50,
  245. tray_now=0,
  246. )
  247. # db returns: archive, queue_item(None), assignment, spool
  248. db = _mock_db_sequential([archive, None, assignment, spool])
  249. # 30g used from 3MF
  250. filament_usage = [{"slot_id": 1, "used_g": 30.0, "type": "PLA", "color": "#FF0000"}]
  251. with (
  252. patch("backend.app.core.config.settings") as mock_settings,
  253. patch("backend.app.api.routes.settings.get_setting", return_value=None),
  254. patch(
  255. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  256. return_value=filament_usage,
  257. ),
  258. ):
  259. mock_settings.base_dir = MagicMock()
  260. mock_path = MagicMock()
  261. mock_path.exists.return_value = True
  262. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  263. results = await on_print_complete(
  264. printer_id=1,
  265. data={"status": "completed"},
  266. printer_manager=printer_manager,
  267. db=db,
  268. archive_id=10,
  269. )
  270. assert len(results) == 1
  271. assert results[0]["spool_id"] == 1
  272. assert results[0]["weight_used"] == 30.0
  273. # Cost = 30g / 1000 * 25.0 = 0.75
  274. assert results[0]["cost"] == 0.75
  275. @pytest.mark.asyncio
  276. async def test_cost_zero_when_default_cost_is_zero(self):
  277. """Cost is None when both spool cost and default cost are 0."""
  278. # Spool without cost_per_kg
  279. spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)
  280. assignment = _make_assignment(spool_id=1)
  281. archive = _make_archive(archive_id=10)
  282. _active_sessions[1] = PrintSession(
  283. printer_id=1,
  284. print_name="Test",
  285. started_at=datetime.now(timezone.utc),
  286. tray_remain_start={(0, 0): 80},
  287. tray_now_at_start=0,
  288. )
  289. printer_manager = MagicMock()
  290. printer_manager.get_status.return_value = SimpleNamespace(
  291. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  292. progress=100,
  293. layer_num=50,
  294. tray_now=0,
  295. )
  296. # db returns: archive, queue_item(None), assignment, spool
  297. db = _mock_db_sequential([archive, None, assignment, spool])
  298. filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
  299. with (
  300. patch("backend.app.core.config.settings") as mock_settings,
  301. patch("backend.app.api.routes.settings.get_setting", return_value="0.0"), # no default cost
  302. patch(
  303. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  304. return_value=filament_usage,
  305. ),
  306. ):
  307. mock_settings.base_dir = MagicMock()
  308. mock_path = MagicMock()
  309. mock_path.exists.return_value = True
  310. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  311. results = await on_print_complete(
  312. printer_id=1,
  313. data={"status": "completed"},
  314. printer_manager=printer_manager,
  315. db=db,
  316. archive_id=10,
  317. )
  318. assert len(results) == 1
  319. assert results[0]["cost"] is None
  320. @pytest.mark.asyncio
  321. async def test_cost_for_failed_print_uses_actual_usage(self):
  322. """Failed print at 50% progress calculates cost from actual usage."""
  323. spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)
  324. assignment = _make_assignment(spool_id=1)
  325. archive = _make_archive(archive_id=10)
  326. _active_sessions[1] = PrintSession(
  327. printer_id=1,
  328. print_name="Test",
  329. started_at=datetime.now(timezone.utc),
  330. tray_remain_start={(0, 0): 80},
  331. tray_now_at_start=0,
  332. )
  333. # Failed at 50% progress
  334. printer_manager = MagicMock()
  335. printer_manager.get_status.return_value = SimpleNamespace(
  336. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  337. progress=50,
  338. layer_num=25,
  339. tray_now=0,
  340. )
  341. # db returns: archive, queue_item(None), assignment, spool
  342. db = _mock_db_sequential([archive, None, assignment, spool])
  343. # 40g total, but only 50% used
  344. filament_usage = [{"slot_id": 1, "used_g": 40.0, "type": "PLA", "color": "#FF0000"}]
  345. with (
  346. patch("backend.app.core.config.settings") as mock_settings,
  347. patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
  348. patch(
  349. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  350. return_value=filament_usage,
  351. ),
  352. patch(
  353. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  354. return_value=None, # No layer data, use linear scaling
  355. ),
  356. ):
  357. mock_settings.base_dir = MagicMock()
  358. mock_path = MagicMock()
  359. mock_path.exists.return_value = True
  360. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  361. results = await on_print_complete(
  362. printer_id=1,
  363. data={"status": "failed", "last_progress": 50.0},
  364. printer_manager=printer_manager,
  365. db=db,
  366. archive_id=10,
  367. )
  368. assert len(results) == 1
  369. # 50% of 40g = 20g
  370. assert results[0]["weight_used"] == 20.0
  371. # Cost = 20g / 1000 * 20.0 = 0.40
  372. assert results[0]["cost"] == 0.40
  373. async def test_a_failed_first_run_leaves_the_full_print_cost_on_the_archive(self):
  374. """COST-3: an archive's cost is one full print of the file -- what the
  375. queue estimate, plate scaling and billing read it as. A run that failed
  376. half way charged its partial cost to its Print Log entry, and also
  377. wrote it onto the archive, so a reprint was estimated and billed from
  378. half a print. Spoolman mode never did."""
  379. spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)
  380. assignment = _make_assignment(spool_id=1)
  381. archive = _make_archive(archive_id=10)
  382. archive.cost = 5.0 # the full-print estimate from print start
  383. _active_sessions[1] = PrintSession(
  384. printer_id=1,
  385. print_name="Test",
  386. started_at=datetime.now(timezone.utc),
  387. tray_remain_start={(0, 0): 80},
  388. tray_now_at_start=0,
  389. )
  390. # Failed at 50% progress
  391. printer_manager = MagicMock()
  392. printer_manager.get_status.return_value = SimpleNamespace(
  393. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  394. progress=50,
  395. layer_num=25,
  396. tray_now=0,
  397. )
  398. # db returns: archive, queue_item(None), assignment, spool
  399. # ... and the archive again for the cost it carries
  400. db = _mock_db_sequential([archive, None, assignment, spool, archive])
  401. # 40g total, but only 50% used
  402. filament_usage = [{"slot_id": 1, "used_g": 40.0, "type": "PLA", "color": "#FF0000"}]
  403. with (
  404. # Its first run: the archive's cost is the one it would take.
  405. patch("backend.app.services.print_log.other_runs_of", AsyncMock(return_value=[])),
  406. patch("backend.app.core.config.settings") as mock_settings,
  407. patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
  408. patch(
  409. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  410. return_value=filament_usage,
  411. ),
  412. patch(
  413. "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
  414. return_value=None, # No layer data, use linear scaling
  415. ),
  416. ):
  417. mock_settings.base_dir = MagicMock()
  418. mock_path = MagicMock()
  419. mock_path.exists.return_value = True
  420. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  421. results = await on_print_complete(
  422. printer_id=1,
  423. data={"status": "failed", "last_progress": 50.0},
  424. printer_manager=printer_manager,
  425. db=db,
  426. archive_id=10,
  427. )
  428. # The run's own cost is still measured (it goes to its Print Log entry).
  429. assert results[0]["cost"] == 0.40
  430. assert archive.cost == 5.0
  431. async def _failed_after_firmware_reset(self, *, last_progress=0.0, last_layer_num=0, layer_usage=None):
  432. """A failed print whose printer already reset progress and layer to 0."""
  433. spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)
  434. _active_sessions[1] = PrintSession(
  435. printer_id=1,
  436. print_name="Test",
  437. started_at=datetime.now(timezone.utc),
  438. tray_remain_start={(0, 0): 80},
  439. tray_now_at_start=0,
  440. )
  441. printer_manager = MagicMock()
  442. printer_manager.get_status.return_value = SimpleNamespace(
  443. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, progress=0, layer_num=0, tray_now=0
  444. )
  445. db = _mock_db_sequential([_make_archive(archive_id=10), None, _make_assignment(spool_id=1), spool])
  446. cumulative = MagicMock(return_value={0: 1000.0}) # filament 0 = slot 1
  447. with (
  448. patch("backend.app.core.config.settings") as mock_settings,
  449. patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
  450. patch(
  451. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  452. return_value=[{"slot_id": 1, "used_g": 40.0, "type": "PLA", "color": "#FF0000"}],
  453. ),
  454. patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=layer_usage),
  455. patch("backend.app.utils.threemf_tools.get_cumulative_usage_at_layer", cumulative),
  456. patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
  457. patch("backend.app.utils.threemf_tools.mm_to_grams", return_value=7.0),
  458. ):
  459. mock_settings.base_dir = MagicMock()
  460. mock_path = MagicMock()
  461. mock_path.exists.return_value = True
  462. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  463. results = await on_print_complete(
  464. printer_id=1,
  465. data={"status": "failed", "last_progress": last_progress, "last_layer_num": last_layer_num},
  466. printer_manager=printer_manager,
  467. db=db,
  468. archive_id=10,
  469. )
  470. return results, cumulative
  471. @pytest.mark.asyncio
  472. async def test_a_progress_reset_to_zero_falls_back_to_the_last_progress(self):
  473. """Firmware zeroes progress on cancel; 0 % would charge nothing."""
  474. results, _ = await self._failed_after_firmware_reset(last_progress=40.0)
  475. # 40 % of 40 g
  476. assert results[0]["weight_used"] == 16.0
  477. @pytest.mark.asyncio
  478. async def test_a_layer_reset_to_zero_falls_back_to_the_last_layer(self):
  479. results, cumulative = await self._failed_after_firmware_reset(
  480. last_progress=40.0, last_layer_num=12, layer_usage={1: {12: 1000.0}}
  481. )
  482. assert cumulative.call_args.args[1] == 12
  483. # The per-layer grams, not the 40 % estimate
  484. assert results[0]["weight_used"] == 7.0
  485. @pytest.mark.asyncio
  486. async def test_cost_with_ams_fallback_tracking(self):
  487. """AMS fallback tracking also calculates cost correctly."""
  488. spool = _make_spool(spool_id=2, label_weight=1000, cost_per_kg=30.0)
  489. assignment = _make_assignment(spool_id=2)
  490. _active_sessions[1] = PrintSession(
  491. printer_id=1,
  492. print_name="Test",
  493. started_at=datetime.now(timezone.utc),
  494. tray_remain_start={(0, 0): 80},
  495. )
  496. printer_manager = MagicMock()
  497. printer_manager.get_status.return_value = SimpleNamespace(
  498. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  499. tray_now=0,
  500. last_loaded_tray=-1,
  501. )
  502. # Pad 2 Nones for _find_3mf_by_filename DB queries (library + archive search),
  503. # then assignment and spool for the AMS fallback path
  504. db = _mock_db_sequential([None, None, assignment, spool])
  505. with patch("backend.app.api.routes.settings.get_setting", return_value="15.0"):
  506. results = await on_print_complete(
  507. printer_id=1,
  508. data={"status": "completed"},
  509. printer_manager=printer_manager,
  510. db=db,
  511. archive_id=None, # No archive = AMS fallback
  512. )
  513. assert len(results) == 1
  514. assert results[0]["spool_id"] == 2
  515. # 10% of 1000g = 100g
  516. assert results[0]["weight_used"] == 100.0
  517. # Cost = 100g / 1000 * 30.0 = 3.00
  518. assert results[0]["cost"] == 3.0
  519. @pytest.mark.asyncio
  520. async def test_multi_filament_cost_aggregation(self):
  521. """Multiple spools in one print have their costs tracked separately."""
  522. spool1 = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)
  523. spool2 = _make_spool(spool_id=2, label_weight=1000, cost_per_kg=25.0)
  524. assignment1 = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
  525. assignment2 = _make_assignment(spool_id=2, ams_id=0, tray_id=1)
  526. archive = _make_archive(archive_id=10)
  527. _active_sessions[1] = PrintSession(
  528. printer_id=1,
  529. print_name="Test",
  530. started_at=datetime.now(timezone.utc),
  531. tray_remain_start={(0, 0): 80, (0, 1): 90},
  532. tray_now_at_start=0,
  533. )
  534. printer_manager = MagicMock()
  535. printer_manager.get_status.return_value = SimpleNamespace(
  536. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}, {"id": 1, "remain": 80}]}]},
  537. progress=100,
  538. layer_num=50,
  539. tray_now=0,
  540. )
  541. # Mock slot-to-tray mapping: slot 1 -> tray 0, slot 2 -> tray 1
  542. ams_mapping = [0, 1]
  543. # db returns: archive, assignment1, spool1, assignment2, spool2
  544. # db: archive, queue item for the plate (None), then assignments and
  545. # spools; the given ams_mapping skips only the mapping fallback
  546. db = _mock_db_sequential([archive, None, assignment1, spool1, assignment2, spool2])
  547. # Two filaments used
  548. filament_usage = [
  549. {"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"},
  550. {"slot_id": 2, "used_g": 25.0, "type": "PLA", "color": "#00FF00"},
  551. ]
  552. with (
  553. patch("backend.app.core.config.settings") as mock_settings,
  554. patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
  555. patch(
  556. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  557. return_value=filament_usage,
  558. ),
  559. ):
  560. mock_settings.base_dir = MagicMock()
  561. mock_path = MagicMock()
  562. mock_path.exists.return_value = True
  563. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  564. results = await on_print_complete(
  565. printer_id=1,
  566. data={"status": "completed"},
  567. printer_manager=printer_manager,
  568. db=db,
  569. archive_id=10,
  570. ams_mapping=ams_mapping,
  571. )
  572. assert len(results) == 2
  573. # First spool: 15g at 20/kg = 0.30
  574. spool1_result = next(r for r in results if r["spool_id"] == 1)
  575. assert spool1_result["weight_used"] == 15.0
  576. assert spool1_result["cost"] == 0.30
  577. # Second spool: 25g at 25/kg = 0.625, rounded to 0.62
  578. spool2_result = next(r for r in results if r["spool_id"] == 2)
  579. assert spool2_result["weight_used"] == 25.0
  580. assert spool2_result["cost"] == 0.62
  581. class TestCostAggregation:
  582. """Tests for cost aggregation to PrintArchive."""
  583. @pytest.mark.asyncio
  584. async def test_costs_summed_in_results(self):
  585. """Multiple spool costs are correctly summed from result dicts."""
  586. results = [
  587. {"spool_id": 1, "weight_used": 20.0, "cost": 0.50},
  588. {"spool_id": 2, "weight_used": 30.0, "cost": 0.75},
  589. ]
  590. total_cost = sum(r.get("cost", 0) or 0 for r in results)
  591. assert total_cost == 1.25
  592. @pytest.mark.asyncio
  593. async def test_null_costs_handled_in_aggregation(self):
  594. """None costs don't break aggregation."""
  595. results = [
  596. {"spool_id": 1, "weight_used": 20.0, "cost": 0.50},
  597. {"spool_id": 2, "weight_used": 30.0, "cost": None}, # No cost
  598. {"spool_id": 3, "weight_used": 10.0, "cost": 0.25},
  599. ]
  600. total_cost = sum(r.get("cost", 0) or 0 for r in results)
  601. assert total_cost == 0.75 # Only spools 1 and 3
  602. @pytest.mark.asyncio
  603. async def test_archive_cost_not_overwritten_with_zero(self):
  604. """archive.cost is preserved when no spool usage has cost data."""
  605. # Spool without cost_per_kg, default_filament_cost also 0 → cost=None per usage
  606. spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)
  607. assignment = _make_assignment(spool_id=1)
  608. archive = _make_archive(archive_id=10)
  609. archive.cost = 5.00 # Pre-existing cost from catalog
  610. archive.print_name = "TestPrint"
  611. archive.printer_id = 1
  612. _active_sessions[1] = PrintSession(
  613. printer_id=1,
  614. print_name="TestPrint",
  615. started_at=datetime.now(timezone.utc),
  616. tray_remain_start={(0, 0): 80},
  617. tray_now_at_start=0,
  618. )
  619. printer_manager = MagicMock()
  620. printer_manager.get_status.return_value = SimpleNamespace(
  621. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  622. progress=100,
  623. layer_num=50,
  624. tray_now=0,
  625. )
  626. # Build mock db that returns proper scalars for the aggregation queries
  627. responses = []
  628. # 1. select(PrintArchive) → archive
  629. responses.append(("scalar_one_or_none", archive))
  630. # 2. select(PrintQueueItem) → None
  631. responses.append(("scalar_one_or_none", None))
  632. # 3. select(SpoolAssignment) → assignment
  633. responses.append(("scalar_one_or_none", assignment))
  634. # 4. select(Spool) → spool
  635. responses.append(("scalar_one_or_none", spool))
  636. # 5. cost aggregation: select archive to update cost
  637. responses.append(("scalar_one_or_none", archive))
  638. db = AsyncMock()
  639. call_count = [0]
  640. async def mock_execute(*args, **kwargs):
  641. idx = call_count[0]
  642. call_count[0] += 1
  643. result = MagicMock()
  644. if idx < len(responses):
  645. method, value = responses[idx]
  646. if method == "scalar":
  647. result.scalar.return_value = value
  648. result.scalar_one_or_none.return_value = value
  649. else:
  650. result.scalar_one_or_none.return_value = value
  651. result.scalar.return_value = value
  652. else:
  653. result.scalar_one_or_none.return_value = None
  654. result.scalar.return_value = None
  655. return result
  656. db.execute = mock_execute
  657. filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
  658. with (
  659. patch("backend.app.core.config.settings") as mock_settings,
  660. patch("backend.app.api.routes.settings.get_setting", return_value="0.0"), # no default cost
  661. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  662. ):
  663. mock_settings.base_dir = MagicMock()
  664. mock_path = MagicMock()
  665. mock_path.exists.return_value = True
  666. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  667. results = await on_print_complete(
  668. printer_id=1,
  669. data={"status": "completed"},
  670. printer_manager=printer_manager,
  671. db=db,
  672. archive_id=10,
  673. )
  674. # Usage tracked but cost is None (no cost_per_kg, no default)
  675. assert len(results) == 1
  676. assert results[0]["cost"] is None
  677. # Archive cost should NOT have been overwritten — still 5.00
  678. assert archive.cost == 5.00
  679. @pytest.mark.asyncio
  680. async def test_archive_cost_set_when_spool_has_cost(self):
  681. """archive.cost is set from spool usage when cost data exists."""
  682. spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)
  683. assignment = _make_assignment(spool_id=1)
  684. archive = _make_archive(archive_id=10)
  685. archive.cost = None # No pre-existing cost
  686. archive.print_name = "TestPrint"
  687. archive.printer_id = 1
  688. _active_sessions[1] = PrintSession(
  689. printer_id=1,
  690. print_name="TestPrint",
  691. started_at=datetime.now(timezone.utc),
  692. tray_remain_start={(0, 0): 80},
  693. tray_now_at_start=0,
  694. )
  695. printer_manager = MagicMock()
  696. printer_manager.get_status.return_value = SimpleNamespace(
  697. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  698. progress=100,
  699. layer_num=50,
  700. tray_now=0,
  701. )
  702. # 20g at 25/kg = 0.50
  703. expected_cost = 0.50
  704. responses = []
  705. responses.append(("scalar_one_or_none", archive))
  706. responses.append(("scalar_one_or_none", None)) # queue item
  707. responses.append(("scalar_one_or_none", assignment))
  708. responses.append(("scalar_one_or_none", spool))
  709. # cost aggregation: select archive to update cost
  710. responses.append(("scalar_one_or_none", archive))
  711. db = AsyncMock()
  712. call_count = [0]
  713. async def mock_execute(*args, **kwargs):
  714. idx = call_count[0]
  715. call_count[0] += 1
  716. result = MagicMock()
  717. if idx < len(responses):
  718. method, value = responses[idx]
  719. result.scalar.return_value = value
  720. result.scalar_one_or_none.return_value = value
  721. else:
  722. result.scalar_one_or_none.return_value = None
  723. result.scalar.return_value = None
  724. return result
  725. db.execute = mock_execute
  726. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
  727. with (
  728. # The first run of this archive
  729. patch("backend.app.services.print_log.other_runs_of", AsyncMock(return_value=0)),
  730. patch("backend.app.core.config.settings") as mock_settings,
  731. patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
  732. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  733. ):
  734. mock_settings.base_dir = MagicMock()
  735. mock_path = MagicMock()
  736. mock_path.exists.return_value = True
  737. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  738. results = await on_print_complete(
  739. printer_id=1,
  740. data={"status": "completed"},
  741. printer_manager=printer_manager,
  742. db=db,
  743. archive_id=10,
  744. )
  745. assert len(results) == 1
  746. assert results[0]["cost"] == expected_cost
  747. # Archive cost should have been updated
  748. assert archive.cost == expected_cost
  749. @pytest.mark.asyncio
  750. async def test_archive_cost_includes_untracked_filament_at_default_rate(self):
  751. """#1344: when only some AMS trays have inventory spools, the untracked
  752. filament weight is charged at the global default rate so the total
  753. archive cost still reflects the whole print."""
  754. spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=10.0)
  755. assignment = _make_assignment(spool_id=1)
  756. archive = _make_archive(archive_id=10)
  757. archive.cost = None
  758. archive.print_name = "TestPrint"
  759. archive.printer_id = 1
  760. archive.filament_used_grams = 110.0 # whole-print weight from slicer
  761. _active_sessions[1] = PrintSession(
  762. printer_id=1,
  763. print_name="TestPrint",
  764. started_at=datetime.now(timezone.utc),
  765. tray_remain_start={(0, 0): 80},
  766. tray_now_at_start=0,
  767. )
  768. printer_manager = MagicMock()
  769. printer_manager.get_status.return_value = SimpleNamespace(
  770. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  771. progress=100,
  772. layer_num=50,
  773. tray_now=0,
  774. )
  775. responses = [
  776. ("scalar_one_or_none", archive),
  777. ("scalar_one_or_none", None), # queue item
  778. ("scalar_one_or_none", assignment),
  779. ("scalar_one_or_none", spool),
  780. ("scalar_one_or_none", archive), # cost-update select
  781. ]
  782. db = AsyncMock()
  783. call_count = [0]
  784. async def mock_execute(*args, **kwargs):
  785. idx = call_count[0]
  786. call_count[0] += 1
  787. result = MagicMock()
  788. if idx < len(responses):
  789. _, value = responses[idx]
  790. result.scalar.return_value = value
  791. result.scalar_one_or_none.return_value = value
  792. else:
  793. result.scalar_one_or_none.return_value = None
  794. result.scalar.return_value = None
  795. return result
  796. db.execute = mock_execute
  797. # 3MF reports a single slot using 10g, but archive.filament_used_grams
  798. # says the whole print was 110g -- the other 100g came from spools that
  799. # aren't in inventory.
  800. filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
  801. with (
  802. # The first run of this archive
  803. patch("backend.app.services.print_log.other_runs_of", AsyncMock(return_value=0)),
  804. patch("backend.app.core.config.settings") as mock_settings,
  805. patch("backend.app.api.routes.settings.get_setting", return_value="10.0"),
  806. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  807. ):
  808. mock_settings.base_dir = MagicMock()
  809. mock_path = MagicMock()
  810. mock_path.exists.return_value = True
  811. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  812. results = await on_print_complete(
  813. printer_id=1,
  814. data={"status": "completed"},
  815. printer_manager=printer_manager,
  816. db=db,
  817. archive_id=10,
  818. )
  819. # Tracked slot: 10g * $10/kg = $0.10
  820. assert len(results) == 1
  821. assert results[0]["cost"] == 0.10
  822. # Untracked: 110g - 10g = 100g at $10/kg default = $1.00
  823. # Archive total: $0.10 + $1.00 = $1.10 (was $0.01 pre-fix because only
  824. # the tracked slot's tiny share was kept)
  825. assert archive.cost == 1.10
  826. @pytest.mark.asyncio
  827. async def test_archive_cost_fully_tracked_unchanged_by_topup(self):
  828. """When every gram is covered by inventory spools, the default-rate
  829. top-up adds nothing -- the archive cost is just the sum of tracked
  830. costs, same as before #1344."""
  831. spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)
  832. assignment = _make_assignment(spool_id=1)
  833. archive = _make_archive(archive_id=10)
  834. archive.cost = None
  835. archive.print_name = "TestPrint"
  836. archive.printer_id = 1
  837. archive.filament_used_grams = 20.0 # exactly what the slot reports
  838. _active_sessions[1] = PrintSession(
  839. printer_id=1,
  840. print_name="TestPrint",
  841. started_at=datetime.now(timezone.utc),
  842. tray_remain_start={(0, 0): 80},
  843. tray_now_at_start=0,
  844. )
  845. printer_manager = MagicMock()
  846. printer_manager.get_status.return_value = SimpleNamespace(
  847. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  848. progress=100,
  849. layer_num=50,
  850. tray_now=0,
  851. )
  852. responses = [
  853. ("scalar_one_or_none", archive),
  854. ("scalar_one_or_none", None),
  855. ("scalar_one_or_none", assignment),
  856. ("scalar_one_or_none", spool),
  857. ("scalar_one_or_none", archive),
  858. ]
  859. db = AsyncMock()
  860. call_count = [0]
  861. async def mock_execute(*args, **kwargs):
  862. idx = call_count[0]
  863. call_count[0] += 1
  864. result = MagicMock()
  865. if idx < len(responses):
  866. _, value = responses[idx]
  867. result.scalar.return_value = value
  868. result.scalar_one_or_none.return_value = value
  869. else:
  870. result.scalar_one_or_none.return_value = None
  871. result.scalar.return_value = None
  872. return result
  873. db.execute = mock_execute
  874. filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
  875. with (
  876. # The first run of this archive
  877. patch("backend.app.services.print_log.other_runs_of", AsyncMock(return_value=0)),
  878. patch("backend.app.core.config.settings") as mock_settings,
  879. patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
  880. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  881. ):
  882. mock_settings.base_dir = MagicMock()
  883. mock_path = MagicMock()
  884. mock_path.exists.return_value = True
  885. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  886. results = await on_print_complete(
  887. printer_id=1,
  888. data={"status": "completed"},
  889. printer_manager=printer_manager,
  890. db=db,
  891. archive_id=10,
  892. )
  893. # 20g at $25/kg = $0.50 -- no top-up because tracked >= archive grams
  894. assert len(results) == 1
  895. assert results[0]["cost"] == 0.50
  896. assert archive.cost == 0.50
  897. @pytest.mark.asyncio
  898. async def test_cost_with_archive_id(self):
  899. """Test cost aggregation using archive_id (3MF path)."""
  900. spool_new = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)
  901. assignment_new = _make_assignment(spool_id=1)
  902. archive_new = _make_archive(archive_id=20)
  903. filament_usage_new = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
  904. printer_manager = MagicMock()
  905. printer_manager.get_status.return_value = SimpleNamespace(
  906. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  907. progress=100,
  908. layer_num=50,
  909. tray_now=0,
  910. )
  911. db = _mock_db_sequential([archive_new, None, assignment_new, spool_new])
  912. with (
  913. patch("backend.app.core.config.settings") as mock_settings,
  914. patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
  915. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage_new),
  916. ):
  917. mock_settings.base_dir = MagicMock()
  918. mock_path = MagicMock()
  919. mock_path.exists.return_value = True
  920. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  921. results_new = await on_print_complete(
  922. printer_id=1,
  923. data={"status": "completed"},
  924. printer_manager=printer_manager,
  925. db=db,
  926. archive_id=20,
  927. )
  928. assert len(results_new) == 1
  929. assert results_new[0]["spool_id"] == 1
  930. assert results_new[0]["cost"] == 0.50 # 20g / 1000 * 25.0
  931. @pytest.mark.asyncio
  932. async def test_cost_with_print_name_ams_fallback(self):
  933. """Test cost aggregation using print_name (AMS fallback, legacy path)."""
  934. spool_old = _make_spool(spool_id=2, label_weight=1000, cost_per_kg=15.0)
  935. assignment_old = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
  936. legacy_print_name = "LegacyPrint"
  937. _active_sessions[1] = PrintSession(
  938. printer_id=1,
  939. print_name=legacy_print_name,
  940. started_at=datetime.now(timezone.utc),
  941. tray_remain_start={(0, 0): 80},
  942. tray_now_at_start=0,
  943. )
  944. printer_manager = MagicMock()
  945. printer_manager.get_status.return_value = SimpleNamespace(
  946. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
  947. progress=100,
  948. layer_num=50,
  949. tray_now=0,
  950. )
  951. # Pad 2 Nones for _find_3mf_by_filename DB queries (library + archive search),
  952. # then assignment and spool for the AMS fallback path
  953. db = _mock_db_sequential([None, None, assignment_old, spool_old])
  954. with (
  955. patch("backend.app.core.config.settings") as mock_settings,
  956. patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
  957. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=None),
  958. ):
  959. mock_settings.base_dir = MagicMock()
  960. mock_path = MagicMock()
  961. mock_path.exists.return_value = True
  962. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  963. results_old = await on_print_complete(
  964. printer_id=1,
  965. data={"status": "completed", "subtask_name": legacy_print_name, "filename": legacy_print_name},
  966. printer_manager=printer_manager,
  967. db=db,
  968. archive_id=None,
  969. )
  970. assert len(results_old) == 1
  971. assert results_old[0]["spool_id"] == 2
  972. assert results_old[0]["cost"] == 1.5 # 100g / 1000 * 15.0