test_spoolman_recovered_3mf_tracking.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  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, monkeypatch):
  47. from backend.app import main as main_module
  48. from backend.app.core.config import settings as app_config
  49. # Archived under this test's own directory: in the shared archive/, a test
  50. # on another worker recovering the same archive id in the same second
  51. # writes the same 3MF path and this one reads it half-written.
  52. monkeypatch.setattr(app_config, "base_dir", tmp_path)
  53. monkeypatch.setattr(app_config, "archive_dir", tmp_path / "archive")
  54. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  55. # Empty, not whatever a test before on this worker left: the recovery reads
  56. # the printed plate from it, and a plate the 3MF has no usage for tracks
  57. # nothing.
  58. with (
  59. patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
  60. patch.dict(main_module._print_plate_ids, {}, clear=True),
  61. ):
  62. yield maker, printer_id, archive_id
  63. async def test_the_recovered_usage_is_added_and_the_start_snapshot_kept(seeded, tmp_path):
  64. maker, printer_id, archive_id = seeded
  65. await _with(
  66. maker,
  67. printer_id=printer_id,
  68. archive_id=archive_id,
  69. row={"tray_remain_start": REMAIN_AT_START, "tray_now_at_start": 0, "slot_to_tray": [0]},
  70. )
  71. await _recover(maker, tmp_path, printer_id, archive_id)
  72. row = await _row(maker, archive_id)
  73. assert row.filament_usage == USED
  74. # Taken at print start; the recovery is mid-print, so it must not replace them.
  75. assert (row.tray_remain_start, row.tray_now_at_start, row.slot_to_tray, row.ams_trays) == (
  76. REMAIN_AT_START,
  77. 0,
  78. [0],
  79. {},
  80. )
  81. async def test_so_does_the_recovery_at_the_print_s_end(seeded, tmp_path):
  82. """It runs just before Spoolman is charged, so the charge reads it."""
  83. maker, printer_id, archive_id = seeded
  84. await _with(maker, printer_id=printer_id, archive_id=archive_id, row={"tray_remain_start": REMAIN_AT_START})
  85. await _recover(maker, tmp_path, printer_id, archive_id, price=False)
  86. assert (await _row(maker, archive_id)).filament_usage == USED
  87. async def test_usage_already_tracked_is_left_alone(seeded, tmp_path):
  88. maker, printer_id, archive_id = seeded
  89. own = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FFFFFF"}]
  90. await _with(maker, printer_id=printer_id, archive_id=archive_id, row={"filament_usage": own})
  91. await _recover(maker, tmp_path, printer_id, archive_id)
  92. assert (await _row(maker, archive_id)).filament_usage == own
  93. async def test_a_print_tracked_with_nothing_at_its_start_is_tracked_now(seeded, tmp_path):
  94. """No slot had a remain% at start, so nothing was stored and the print would
  95. be charged nothing. The 3MF is all there is; no start snapshot is invented."""
  96. maker, printer_id, archive_id = seeded
  97. await _with(maker, printer_id=printer_id, archive_id=archive_id)
  98. await _recover(maker, tmp_path, printer_id, archive_id)
  99. row = await _row(maker, archive_id)
  100. assert row.filament_usage == USED
  101. assert row.tray_remain_start is None
  102. assert row.tray_now_at_start is None
  103. async def test_the_recovery_at_the_print_s_end_tracks_it_too(seeded, tmp_path):
  104. """It runs inside the close-out (marked as such), before the charge, so the
  105. charge reads it."""
  106. from backend.app.services import usage_tracker
  107. maker, printer_id, archive_id = seeded
  108. await _with(maker, printer_id=printer_id, archive_id=archive_id)
  109. usage_tracker.mark_completing(printer_id)
  110. try:
  111. await _recover(maker, tmp_path, printer_id, archive_id, price=False)
  112. finally:
  113. usage_tracker.done_completing(printer_id)
  114. assert (await _row(maker, archive_id)).filament_usage == USED
  115. async def test_no_other_recovery_starts_tracking_while_the_print_is_closed(seeded, tmp_path):
  116. """The cover endpoint's download can land while the print is being closed:
  117. tracking made then could follow the charge that already deleted it, and be
  118. left behind, making the printer look mid-print to later spool archiving."""
  119. from backend.app.services import usage_tracker
  120. maker, printer_id, archive_id = seeded
  121. await _with(maker, printer_id=printer_id, archive_id=archive_id)
  122. usage_tracker.mark_completing(printer_id)
  123. try:
  124. await _recover(maker, tmp_path, printer_id, archive_id)
  125. finally:
  126. usage_tracker.done_completing(printer_id)
  127. assert await _row(maker, archive_id) is None
  128. async def test_nothing_without_spoolman(seeded, tmp_path):
  129. maker, printer_id, archive_id = seeded
  130. await _with(maker, spoolman=False, printer_id=printer_id, archive_id=archive_id)
  131. await _recover(maker, tmp_path, printer_id, archive_id)
  132. assert await _row(maker, archive_id) is None