test_run_filament_helper.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. """Unit tests for the per-run filament helper (#1378, #1390).
  2. The helper computes what value to write into PrintLogEntry.filament_used_grams
  3. for a given print event — partial-aware so failed / cancelled / stopped prints
  4. don't inflate stats with the full slicer estimate, and tracker-aware so
  5. completed prints agree with the per-spool counter on the Inventory page.
  6. """
  7. from types import SimpleNamespace
  8. import backend.app.utils.threemf_tools as threemf_tools
  9. from backend.app.main import _compute_run_filament_grams, _plate_scoped_run_estimate
  10. class TestComputeRunFilamentGrams:
  11. def test_completed_no_tracker_returns_archive_estimate(self):
  12. # Completed print without inventory tracking: the slicer estimate is
  13. # the canonical "this print used X" value.
  14. assert _compute_run_filament_grams("completed", 100.0, 100, []) == 100.0
  15. def test_completed_prefers_tracked_over_estimate(self):
  16. # #1390: when inventory tracked the AMS weight delta, Stats should
  17. # reflect that — same source that drives "Total Consumed" on the
  18. # Inventory page. Two halves of the app must show the same number.
  19. assert _compute_run_filament_grams("completed", 100.0, 100, [{"weight_used": 96.5}]) == 96.5
  20. def test_failed_uses_tracked_spool_delta(self):
  21. # Failed reprint at 10g actual: inventory tracked the spool delta.
  22. # The estimate was 100g; we want 10g recorded for stats.
  23. assert _compute_run_filament_grams("failed", 100.0, 10, [{"weight_used": 10.0}]) == 10.0
  24. def test_cancelled_uses_tracked_spool_delta(self):
  25. # Same logic for cancelled.
  26. assert _compute_run_filament_grams("cancelled", 100.0, 12, [{"weight_used": 8.5}]) == 8.5
  27. def test_stopped_uses_tracked_spool_delta(self):
  28. assert _compute_run_filament_grams("stopped", 100.0, 15, [{"weight_used": 12.0}]) == 12.0
  29. def test_failed_with_no_tracked_falls_back_to_progress_scale(self):
  30. # No inventory tracking: scale estimate by progress% (10% of 100g = 10g).
  31. assert _compute_run_filament_grams("failed", 100.0, 10, []) == 10.0
  32. def test_failed_with_no_tracked_and_no_progress_returns_none(self):
  33. # Nothing to infer from — return None rather than guess the estimate.
  34. assert _compute_run_filament_grams("failed", 100.0, 0, []) is None
  35. def test_failed_with_partial_progress_rounds_correctly(self):
  36. # 100g × 33% = 33.0g (rounded to 1 decimal)
  37. assert _compute_run_filament_grams("failed", 100.0, 33, []) == 33.0
  38. def test_failed_with_no_estimate_returns_none(self):
  39. # No estimate, no tracked usage → can't compute anything.
  40. assert _compute_run_filament_grams("failed", None, 50, []) is None
  41. def test_failed_with_no_estimate_but_tracked_uses_tracked(self):
  42. # Tracked spool delta is authoritative even without an estimate.
  43. assert _compute_run_filament_grams("failed", None, 50, [{"weight_used": 5.0}]) == 5.0
  44. def test_tracked_overrides_progress_scale_when_both_available(self):
  45. # If inventory says 8g but progress says 15g, trust inventory (it's measured).
  46. assert _compute_run_filament_grams("failed", 100.0, 15, [{"weight_used": 8.0}]) == 8.0
  47. def test_progress_above_100_clamps_to_full_estimate(self):
  48. # Defensive: progress overshoot doesn't multiply past the estimate.
  49. assert _compute_run_filament_grams("failed", 100.0, 150, []) == 100.0
  50. def test_multiple_tracked_slots_summed(self):
  51. # Multi-filament print, two slots tracked.
  52. usage = [{"weight_used": 5.0}, {"weight_used": 3.5}, {"weight_used": 1.0}]
  53. assert _compute_run_filament_grams("failed", 100.0, 20, usage) == 9.5
  54. def test_completed_with_none_estimate_returns_none(self):
  55. # Archive somehow has no estimate (rare; archive_print parsed nothing).
  56. assert _compute_run_filament_grams("completed", None, 100, []) is None
  57. class TestPlateScopedRunEstimate:
  58. """#2614: a plate dispatched from a multi-plate 3MF must log only that plate's
  59. filament/cost, not the archive's whole-file totals."""
  60. def _archive(self, **kw):
  61. return SimpleNamespace(
  62. id=1,
  63. plate_id=kw.get("plate_id", 3),
  64. filament_used_grams=kw.get("filament_used_grams", 12006.49),
  65. cost=kw.get("cost", 240.13),
  66. file_path=kw.get("file_path", "archive/1/heart.gcode.3mf"),
  67. )
  68. def _patch_plate_grams(self, monkeypatch, grams):
  69. monkeypatch.setattr(
  70. threemf_tools,
  71. "extract_plate_metadata_from_3mf",
  72. lambda path, plate_id: SimpleNamespace(filament_used_grams=grams),
  73. )
  74. def test_scopes_grams_and_scales_cost_to_plate(self, monkeypatch, tmp_path):
  75. f = tmp_path / "heart.gcode.3mf"
  76. f.write_bytes(b"stub")
  77. self._patch_plate_grams(monkeypatch, 350.0)
  78. grams, cost = _plate_scoped_run_estimate(self._archive(), f)
  79. assert grams == 350.0
  80. # cost scaled by the plate's share of the whole-file grams.
  81. assert cost == round(240.13 * (350.0 / 12006.49), 2)
  82. def test_no_plate_id_returns_whole_file_values(self, monkeypatch, tmp_path):
  83. f = tmp_path / "heart.gcode.3mf"
  84. f.write_bytes(b"stub")
  85. # Extractor must not even be consulted.
  86. self._patch_plate_grams(monkeypatch, 350.0)
  87. grams, cost = _plate_scoped_run_estimate(self._archive(plate_id=None), f)
  88. assert (grams, cost) == (12006.49, 240.13)
  89. def test_missing_file_returns_whole_file_values(self, monkeypatch):
  90. from pathlib import Path
  91. self._patch_plate_grams(monkeypatch, 350.0)
  92. grams, cost = _plate_scoped_run_estimate(self._archive(), Path("/nope/gone.3mf"))
  93. assert (grams, cost) == (12006.49, 240.13)
  94. def test_zero_plate_estimate_falls_back(self, monkeypatch, tmp_path):
  95. f = tmp_path / "heart.gcode.3mf"
  96. f.write_bytes(b"stub")
  97. self._patch_plate_grams(monkeypatch, 0.0)
  98. grams, cost = _plate_scoped_run_estimate(self._archive(), f)
  99. assert (grams, cost) == (12006.49, 240.13)
  100. def test_extractor_error_falls_back(self, monkeypatch, tmp_path):
  101. f = tmp_path / "heart.gcode.3mf"
  102. f.write_bytes(b"stub")
  103. def _boom(path, plate_id):
  104. raise ValueError("corrupt 3mf")
  105. monkeypatch.setattr(threemf_tools, "extract_plate_metadata_from_3mf", _boom)
  106. grams, cost = _plate_scoped_run_estimate(self._archive(), f)
  107. assert (grams, cost) == (12006.49, 240.13)
  108. def test_no_archive_cost_keeps_cost_none(self, monkeypatch, tmp_path):
  109. f = tmp_path / "heart.gcode.3mf"
  110. f.write_bytes(b"stub")
  111. self._patch_plate_grams(monkeypatch, 350.0)
  112. grams, cost = _plate_scoped_run_estimate(self._archive(cost=None), f)
  113. assert grams == 350.0
  114. assert cost is None