test_spoolman_recovered_3mf_tracking.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. """A recovered 3MF reaches Spoolman's usage tracking (late-3MF item).
  2. A print that started without its 3MF gets a fallback archive, and Spoolman's
  3. print-start tracking stores no per-filament usage for it: completion charges
  4. the AMS remain% change instead, in 10 g steps on a 1 kg spool, nothing for a
  5. slot without a remain%, and nothing at all when no slot had one. When the 3MF
  6. turned up later, the recovery filled the archive in but left Spoolman's
  7. tracking as it was; the built-in inventory reads the archive at completion and
  8. was not affected. The recovered 3MF's usage is now added to it.
  9. """
  10. from __future__ import annotations
  11. from types import SimpleNamespace
  12. from unittest.mock import MagicMock, patch
  13. import pytest
  14. from sqlalchemy import select
  15. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  16. from backend.app.models.settings import Settings
  17. from backend.tests.unit.test_fallback_archive_recovery_2957 import DISPATCH_FILENAME, _seed, _write_3mf
  18. pytestmark = pytest.mark.asyncio
  19. USED = [{"slot_id": 1, "used_g": 42.5, "type": "PLA", "color": "#00AE42"}]
  20. REMAIN_AT_START = {"0:0": {"remain": 80, "tray_uuid": "A" * 32}}
  21. async def _with(maker, *, spoolman: bool = True, row: dict | None = None, printer_id: int, archive_id: int) -> None:
  22. async with maker() as db:
  23. if spoolman:
  24. db.add(Settings(key="spoolman_enabled", value="true"))
  25. if row is not None:
  26. db.add(ActivePrintSpoolman(printer_id=printer_id, archive_id=archive_id, ams_trays={}, **row))
  27. await db.commit()
  28. async def _recover(maker, tmp_path, printer_id: int, archive_id: int, *, price: bool = True) -> None:
  29. from backend.app import main as main_module
  30. source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
  31. printer_manager = MagicMock()
  32. printer_manager.get_status.return_value = SimpleNamespace(
  33. raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 71, "tray_type": "PLA"}]}]}, tray_now=0
  34. )
  35. with (
  36. patch.object(main_module, "async_session", maker),
  37. patch.object(main_module, "printer_manager", printer_manager),
  38. ):
  39. await main_module._recover_fallback_archive_locked(archive_id, source, printer_id, price=price)
  40. async def _row(maker, archive_id: int) -> ActivePrintSpoolman | None:
  41. async with maker() as db:
  42. return (
  43. await db.execute(select(ActivePrintSpoolman).where(ActivePrintSpoolman.archive_id == archive_id))
  44. ).scalar_one_or_none()
  45. @pytest.fixture
  46. async def seeded(test_engine, tmp_path):
  47. from backend.app import main as main_module
  48. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  49. # Empty, not whatever a test before on this worker left: the recovery reads
  50. # the printed plate from it, and a plate the 3MF has no usage for tracks
  51. # nothing.
  52. with (
  53. patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
  54. patch.dict(main_module._print_plate_ids, {}, clear=True),
  55. ):
  56. yield maker, printer_id, archive_id
  57. async def test_the_recovered_usage_is_added_and_the_start_snapshot_kept(seeded, tmp_path):
  58. maker, printer_id, archive_id = seeded
  59. await _with(
  60. maker,
  61. printer_id=printer_id,
  62. archive_id=archive_id,
  63. row={"tray_remain_start": REMAIN_AT_START, "tray_now_at_start": 0, "slot_to_tray": [0]},
  64. )
  65. await _recover(maker, tmp_path, printer_id, archive_id)
  66. row = await _row(maker, archive_id)
  67. assert row.filament_usage == USED
  68. # Taken at print start; the recovery is mid-print, so it must not replace them.
  69. assert (row.tray_remain_start, row.tray_now_at_start, row.slot_to_tray, row.ams_trays) == (
  70. REMAIN_AT_START,
  71. 0,
  72. [0],
  73. {},
  74. )
  75. async def test_so_does_the_recovery_at_the_print_s_end(seeded, tmp_path):
  76. """It runs just before Spoolman is charged, so the charge reads it."""
  77. maker, printer_id, archive_id = seeded
  78. await _with(maker, printer_id=printer_id, archive_id=archive_id, row={"tray_remain_start": REMAIN_AT_START})
  79. await _recover(maker, tmp_path, printer_id, archive_id, price=False)
  80. assert (await _row(maker, archive_id)).filament_usage == USED
  81. async def test_usage_already_tracked_is_left_alone(seeded, tmp_path):
  82. maker, printer_id, archive_id = seeded
  83. own = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FFFFFF"}]
  84. await _with(maker, printer_id=printer_id, archive_id=archive_id, row={"filament_usage": own})
  85. await _recover(maker, tmp_path, printer_id, archive_id)
  86. assert (await _row(maker, archive_id)).filament_usage == own
  87. async def test_a_print_tracked_with_nothing_at_its_start_is_tracked_now(seeded, tmp_path):
  88. """No slot had a remain% at start, so nothing was stored and the print would
  89. be charged nothing. The 3MF is all there is; no start snapshot is invented."""
  90. maker, printer_id, archive_id = seeded
  91. await _with(maker, printer_id=printer_id, archive_id=archive_id)
  92. await _recover(maker, tmp_path, printer_id, archive_id)
  93. row = await _row(maker, archive_id)
  94. assert row.filament_usage == USED
  95. assert row.tray_remain_start is None
  96. assert row.tray_now_at_start is None
  97. async def test_the_recovery_at_the_print_s_end_tracks_it_too(seeded, tmp_path):
  98. """It runs inside the close-out (marked as such), before the charge, so the
  99. charge reads it."""
  100. from backend.app.services import usage_tracker
  101. maker, printer_id, archive_id = seeded
  102. await _with(maker, printer_id=printer_id, archive_id=archive_id)
  103. usage_tracker.mark_completing(printer_id)
  104. try:
  105. await _recover(maker, tmp_path, printer_id, archive_id, price=False)
  106. finally:
  107. usage_tracker.done_completing(printer_id)
  108. assert (await _row(maker, archive_id)).filament_usage == USED
  109. async def test_no_other_recovery_starts_tracking_while_the_print_is_closed(seeded, tmp_path):
  110. """The cover endpoint's download can land while the print is being closed:
  111. tracking made then could follow the charge that already deleted it, and be
  112. left behind, making the printer look mid-print to later spool archiving."""
  113. from backend.app.services import usage_tracker
  114. maker, printer_id, archive_id = seeded
  115. await _with(maker, printer_id=printer_id, archive_id=archive_id)
  116. usage_tracker.mark_completing(printer_id)
  117. try:
  118. await _recover(maker, tmp_path, printer_id, archive_id)
  119. finally:
  120. usage_tracker.done_completing(printer_id)
  121. assert await _row(maker, archive_id) is None
  122. async def test_nothing_without_spoolman(seeded, tmp_path):
  123. maker, printer_id, archive_id = seeded
  124. await _with(maker, spoolman=False, printer_id=printer_id, archive_id=archive_id)
  125. await _recover(maker, tmp_path, printer_id, archive_id)
  126. assert await _row(maker, archive_id) is None