test_usage_tracker_charge_race.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. """The tracker adds a print's usage to what is stored, not to its own copy (WU-4).
  2. Completion loads the spool, awaits more lookups, and only then charges it. A
  3. weight set by hand or weighed on the SpoolBuddy scale in between was lost:
  4. the tracker wrote back its earlier copy plus the print's grams. Each test
  5. stores 300 g used (the hand edit) while the tracker's copy still reads the
  6. 100 g it loaded before that edit.
  7. """
  8. from datetime import datetime, timezone
  9. from types import SimpleNamespace
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from sqlalchemy import event, select
  13. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  14. from sqlalchemy.orm.attributes import set_committed_value
  15. from backend.app.models.spool import Spool
  16. from backend.app.services.usage_tracker import (
  17. PrintSession,
  18. _active_sessions,
  19. _charge_spool,
  20. _track_from_3mf,
  21. on_print_complete,
  22. )
  23. pytestmark = pytest.mark.asyncio
  24. HAND_SET = 300.0
  25. def _loaded_before_the_hand_edit(spool, _context):
  26. set_committed_value(spool, "weight_used", 100.0)
  27. @pytest.fixture
  28. def stale_loads():
  29. event.listen(Spool, "load", _loaded_before_the_hand_edit)
  30. yield
  31. event.remove(Spool, "load", _loaded_before_the_hand_edit)
  32. @pytest.fixture
  33. async def maker(test_engine):
  34. return async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  35. async def _spools(maker, count: int = 1) -> list[int]:
  36. async with maker() as db:
  37. rows = [Spool(material="PLA", label_weight=1000, weight_used=HAND_SET) for _ in range(count)]
  38. db.add_all(rows)
  39. await db.commit()
  40. return [row.id for row in rows]
  41. async def _printer(maker) -> int:
  42. from backend.app.models.printer import Printer
  43. async with maker() as db:
  44. printer = Printer(name="P", serial_number="RACE01", ip_address="10.0.0.9", access_code="12345678")
  45. db.add(printer)
  46. await db.commit()
  47. return printer.id
  48. async def _stored(maker, spool_id: int) -> float:
  49. """Read as a column, so the stale-load hook does not touch it."""
  50. async with maker() as db:
  51. return await db.scalar(select(Spool.weight_used).where(Spool.id == spool_id))
  52. async def test_the_charge_adds_to_the_stored_weight(maker, stale_loads):
  53. (spool_id,) = await _spools(maker)
  54. async with maker() as db:
  55. spool = await db.get(Spool, spool_id)
  56. assert spool.weight_used == 100.0, "the copy is the stale one"
  57. await _charge_spool(db, spool, 25.0)
  58. assert spool.weight_used == HAND_SET + 25.0, "the copy follows what is stored"
  59. await db.commit()
  60. assert await _stored(maker, spool_id) == HAND_SET + 25.0
  61. async def test_a_spool_deleted_meanwhile_is_left_alone(maker):
  62. (spool_id,) = await _spools(maker)
  63. async with maker() as db:
  64. spool = await db.get(Spool, spool_id)
  65. async with maker() as other:
  66. await other.delete(await other.get(Spool, spool_id))
  67. await other.commit()
  68. assert await _charge_spool(db, spool, 25.0) is False
  69. await db.commit()
  70. async def _track_3mf(
  71. maker, tmp_path, spool_ids: dict[tuple[int, int], int], slots: int = 1, mapping=(0,), **state
  72. ) -> None:
  73. printer_id = await _printer(maker)
  74. threemf = tmp_path / "part.3mf"
  75. threemf.write_bytes(b"3mf")
  76. status = SimpleNamespace(
  77. progress=100, layer_num=90, tray_now=0, last_loaded_tray=-1, raw_data={}, **{"total_layers": 0, **state}
  78. )
  79. printer_manager = MagicMock()
  80. printer_manager.get_status.return_value = status
  81. async def resolve(printer_id, ams_id, tray_id, **kwargs):
  82. return spool_ids.get((ams_id, tray_id))
  83. usage = [{"slot_id": n, "used_g": 9.0, "type": "PLA", "color": "#FFFFFF"} for n in range(1, slots + 1)]
  84. with (
  85. patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=usage),
  86. patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
  87. patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
  88. patch("backend.app.services.usage_tracker._resolve_spool_id_for_tray", side_effect=resolve),
  89. ):
  90. async with maker() as db:
  91. await _track_from_3mf(
  92. printer_id=printer_id,
  93. archive_id=None,
  94. status="completed",
  95. print_name="part",
  96. handled_trays=set(),
  97. printer_manager=printer_manager,
  98. db=db,
  99. ams_mapping=list(mapping),
  100. print_started_at=datetime.now(timezone.utc),
  101. threemf_path=threemf,
  102. )
  103. await db.commit()
  104. async def test_the_3mf_charge(maker, tmp_path, stale_loads):
  105. (spool_id,) = await _spools(maker)
  106. await _track_3mf(maker, tmp_path, {(0, 0): spool_id}, tray_change_log=[])
  107. assert await _stored(maker, spool_id) == HAND_SET + 9.0
  108. async def test_the_3mf_charge_split_across_trays(maker, tmp_path, stale_loads):
  109. first, second = await _spools(maker, 2)
  110. await _track_3mf(
  111. maker, tmp_path, {(0, 0): first, (0, 1): second}, tray_change_log=[(0, 0), (1, 45)], total_layers=90
  112. )
  113. assert (await _stored(maker, first), await _stored(maker, second)) == (HAND_SET + 4.5, HAND_SET + 4.5)
  114. @pytest.mark.parametrize("deleted", [False, True], ids=["charged", "deleted-meanwhile"])
  115. async def test_the_remain_percentage_charge(maker, stale_loads, deleted):
  116. from backend.app.models.spool_assignment import SpoolAssignment
  117. (spool_id,) = await _spools(maker)
  118. printer_id = await _printer(maker)
  119. async with maker() as db:
  120. db.add(SpoolAssignment(spool_id=spool_id, printer_id=printer_id, ams_id=0, tray_id=0))
  121. await db.commit()
  122. # 80% -> 70% of a 1000 g spool: 100 g.
  123. _active_sessions[printer_id] = PrintSession(
  124. printer_id=printer_id, print_name="part", started_at=datetime.now(timezone.utc), tray_remain_start={(0, 0): 80}
  125. )
  126. status = SimpleNamespace(
  127. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, progress=100, layer_num=0, tray_now=255
  128. )
  129. printer_manager = MagicMock()
  130. printer_manager.get_status.return_value = status
  131. try:
  132. with (
  133. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
  134. patch("backend.app.services.usage_tracker._track_from_3mf", AsyncMock(return_value=[])),
  135. patch(
  136. "backend.app.services.usage_tracker._charge_spool",
  137. _deleting(maker, spool_id if deleted else -1),
  138. ),
  139. ):
  140. async with maker() as db:
  141. await on_print_complete(printer_id, {"status": "completed"}, printer_manager, db)
  142. finally:
  143. _active_sessions.pop(printer_id, None)
  144. if deleted:
  145. assert await _charged(maker) == []
  146. else:
  147. assert await _stored(maker, spool_id) == HAND_SET + 100.0
  148. @pytest.mark.parametrize("split", [True, False], ids=["split-across-trays", "one-tray-per-slot"])
  149. async def test_a_spool_deleted_while_the_print_is_charged_is_skipped(maker, tmp_path, split):
  150. """The rest of the print is still charged: a usage row for the deleted spool
  151. broke the foreign key on PostgreSQL and rolled the whole completion back."""
  152. first, second = await _spools(maker, 2)
  153. with patch("backend.app.services.usage_tracker._charge_spool", _deleting(maker, first)):
  154. if split:
  155. await _track_3mf(
  156. maker, tmp_path, {(0, 0): first, (0, 1): second}, tray_change_log=[(0, 0), (1, 45)], total_layers=90
  157. )
  158. else:
  159. await _track_3mf(maker, tmp_path, {(0, 0): first, (0, 1): second}, slots=2, mapping=(0, 1))
  160. assert await _stored(maker, second) == HAND_SET + (4.5 if split else 9.0)
  161. assert await _charged(maker) == [second]
  162. def _deleting(maker, spool_id: int):
  163. """_charge_spool, with *spool_id* deleted by someone else just before."""
  164. from backend.app.services import usage_tracker
  165. real_charge = usage_tracker._charge_spool
  166. async def charge(db, spool, grams):
  167. if spool.id == spool_id:
  168. async with maker() as other:
  169. await other.delete(await other.get(Spool, spool_id))
  170. await other.commit()
  171. return await real_charge(db, spool, grams)
  172. return charge
  173. async def _charged(maker) -> list[int]:
  174. from backend.app.models.spool_usage_history import SpoolUsageHistory
  175. async with maker() as db:
  176. return list((await db.execute(select(SpoolUsageHistory.spool_id))).scalars().all())