test_shared_tray_usage_3230.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. """Several slicer slots charged to one tray (#3230).
  2. The 3MF pass skipped a tray once it had charged it, so a second slicer slot
  3. mapped to the same tray was dropped. The reporter's H2C printed filaments 1 and
  4. 4 from one AMS-HT tray (mapping ``[128, -1, -1, 128, -1]``) and lost filament
  5. 4's 2.13 g. The same skip dropped the last segment of a single-filament print
  6. that switched to another tray and back. A slot is now charged once and a tray
  7. as often as slots use it.
  8. """
  9. from datetime import datetime, timezone
  10. from types import SimpleNamespace
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from backend.app.models.archive import PrintArchive
  14. from backend.app.models.spool import Spool
  15. from backend.app.services.usage_tracker import _track_from_3mf
  16. pytestmark = pytest.mark.unit
  17. async def _charge_in_memory(_db, spool, grams):
  18. """The fake session has no database for the tracker's UPDATE: add the
  19. grams to the mock spool, which is what these tests check. The statement
  20. itself is tested against a real database in test_usage_tracker_charge_race."""
  21. spool.weight_used = (spool.weight_used or 0) + grams
  22. spool.last_used = datetime.now(timezone.utc)
  23. return True
  24. @pytest.fixture(autouse=True)
  25. def _charge_spools_in_memory():
  26. with patch("backend.app.services.usage_tracker._charge_spool", _charge_in_memory):
  27. yield
  28. def _spool(spool_id: int):
  29. spool = MagicMock()
  30. spool.id = spool_id
  31. spool.label_weight = 1000
  32. spool.weight_used = 0
  33. spool.cost_per_kg = None
  34. spool.material = "PLA"
  35. spool.rgba = None
  36. return spool
  37. def _db(spools: dict[int, MagicMock]):
  38. archive = MagicMock()
  39. archive.id = 3230
  40. archive.file_path = "archives/3230/test.3mf"
  41. archive.extra_data = None
  42. archive.plate_id = None
  43. async def execute(stmt, *args, **kwargs):
  44. entity = stmt.column_descriptions[0].get("entity")
  45. result = MagicMock()
  46. value = None
  47. if entity is PrintArchive:
  48. value = archive
  49. elif entity is Spool:
  50. value = spools.get(stmt.whereclause.right.value)
  51. result.scalar_one_or_none.return_value = value
  52. result.scalars.return_value.first.return_value = None
  53. result.scalar.return_value = None
  54. return result
  55. db = AsyncMock()
  56. db.execute = execute
  57. db.add = MagicMock()
  58. return db
  59. # HT tray 128 holds spool 7; AMS 0 slot 1 holds spool 8.
  60. ASSIGNED = {(128, 0): 7, (0, 1): 8}
  61. async def _track(ams_mapping, filament_usage, tray_change_log=None, total_layers=0, handled_trays=None, raw_data=None):
  62. spools = {7: _spool(7), 8: _spool(8)}
  63. printer_manager = MagicMock()
  64. printer_manager.get_status.return_value = SimpleNamespace(
  65. progress=100,
  66. layer_num=90,
  67. total_layers=total_layers,
  68. tray_now=128,
  69. last_loaded_tray=-1,
  70. tray_change_log=tray_change_log or [],
  71. raw_data=raw_data or {},
  72. )
  73. async def resolve(printer_id, ams_id, tray_id, **kwargs):
  74. return ASSIGNED.get((ams_id, tray_id))
  75. with (
  76. patch("backend.app.core.config.settings") as mock_settings,
  77. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
  78. patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
  79. patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
  80. patch("backend.app.services.usage_tracker._resolve_spool_id_for_tray", side_effect=resolve),
  81. ):
  82. mock_path = MagicMock()
  83. mock_path.exists.return_value = True
  84. mock_settings.base_dir = MagicMock()
  85. mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
  86. results = await _track_from_3mf(
  87. printer_id=1,
  88. archive_id=3230,
  89. status="completed",
  90. print_name="shared_tray",
  91. handled_trays=handled_trays if handled_trays is not None else set(),
  92. printer_manager=printer_manager,
  93. db=_db(spools),
  94. ams_mapping=ams_mapping,
  95. print_started_at=datetime.now(timezone.utc),
  96. )
  97. return results, spools
  98. def _usage(slot_id: int, used_g: float) -> dict:
  99. return {"slot_id": slot_id, "used_g": used_g, "type": "PLA", "color": "#FFFFFF"}
  100. class TestSharedTray:
  101. @pytest.mark.asyncio
  102. async def test_two_slots_on_one_tray_are_both_charged(self):
  103. # The reporter's job, verbatim mapping and grams.
  104. results, spools = await _track([128, -1, -1, 128, -1], [_usage(1, 1.05), _usage(4, 2.13)])
  105. assert [(r["slot_id"], r["spool_id"], r["weight_used"]) for r in results] == [(1, 7, 1.1), (4, 7, 2.1)]
  106. assert spools[7].weight_used == pytest.approx(3.18)
  107. @pytest.mark.asyncio
  108. async def test_slots_on_different_trays_are_unchanged(self):
  109. results, spools = await _track([128, 1], [_usage(1, 1.0), _usage(2, 2.0)])
  110. assert [r["spool_id"] for r in results] == [7, 8]
  111. assert spools[7].weight_used == pytest.approx(1.0)
  112. assert spools[8].weight_used == pytest.approx(2.0)
  113. @pytest.mark.asyncio
  114. async def test_a_shared_tray_is_still_handed_to_the_remain_fallback_as_covered(self):
  115. handled: set = set()
  116. await _track([128, -1, -1, 128, -1], [_usage(1, 1.05), _usage(4, 2.13)], handled_trays=handled)
  117. assert handled == {(128, 0)}
  118. @pytest.mark.asyncio
  119. async def test_a_slot_listed_once_per_plate_is_charged_once(self):
  120. # With the plate unknown, every plate of the file is listed, so slot 1
  121. # appears once per plate. Charging it per plate would multiply the debit.
  122. results, spools = await _track([128], [_usage(1, 4.0), _usage(1, 4.0), _usage(1, 4.0)])
  123. assert len(results) == 1
  124. assert spools[7].weight_used == pytest.approx(4.0)
  125. class TestReturningToATray:
  126. @pytest.mark.asyncio
  127. async def test_every_segment_is_charged_when_a_print_returns_to_an_earlier_tray(self):
  128. # Single-filament print: HT tray, then AMS 0 slot 1, then back to the HT
  129. # tray. Linear split over 90 layers gives a third per segment.
  130. results, spools = await _track(
  131. [128],
  132. [_usage(1, 9.0)],
  133. tray_change_log=[(128, 0), (1, 30), (128, 60)],
  134. total_layers=90,
  135. )
  136. assert [r["spool_id"] for r in results] == [7, 8, 7]
  137. assert spools[7].weight_used == pytest.approx(6.0)
  138. assert spools[8].weight_used == pytest.approx(3.0)
  139. class TestGuessedTrays:
  140. @pytest.mark.asyncio
  141. async def test_a_guessed_tray_already_charged_is_not_charged_again(self):
  142. # No mapping: slot 1 takes the only loaded tray (AMS 0 slot 1, global
  143. # 1), slot 2 is past the loaded trays and falls back to slot_id - 1,
  144. # the same tray. A position guess says nothing about which spool fed
  145. # slot 2, so it is not added to slot 1's spool.
  146. raw = {"ams": [{"id": "0", "tray": [{"id": "1", "tray_type": "PLA"}]}]}
  147. with patch(
  148. "backend.app.services.spoolman_tracking.build_ams_tray_lookup", return_value={1: {"tray_type": "PLA"}}
  149. ):
  150. results, spools = await _track(None, [_usage(1, 1.0), _usage(2, 2.0)], raw_data=raw)
  151. assert [r["slot_id"] for r in results] == [1]
  152. assert spools[8].weight_used == pytest.approx(1.0)