|
@@ -0,0 +1,165 @@
|
|
|
|
|
+"""A recovered 3MF reaches Spoolman's usage tracking (late-3MF item).
|
|
|
|
|
+
|
|
|
|
|
+A print that started without its 3MF gets a fallback archive, and Spoolman's
|
|
|
|
|
+print-start tracking stores no per-filament usage for it: completion charges
|
|
|
|
|
+the AMS remain% change instead, in 10 g steps on a 1 kg spool, nothing for a
|
|
|
|
|
+slot without a remain%, and nothing at all when no slot had one. When the 3MF
|
|
|
|
|
+turned up later, the recovery filled the archive in but left Spoolman's
|
|
|
|
|
+tracking as it was; the built-in inventory reads the archive at completion and
|
|
|
|
|
+was not affected. The recovered 3MF's usage is now added to it.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+from types import SimpleNamespace
|
|
|
|
|
+from unittest.mock import MagicMock, patch
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+from sqlalchemy import select
|
|
|
|
|
+
|
|
|
|
|
+from backend.app.models.active_print_spoolman import ActivePrintSpoolman
|
|
|
|
|
+from backend.app.models.settings import Settings
|
|
|
|
|
+from backend.tests.unit.test_fallback_archive_recovery_2957 import DISPATCH_FILENAME, _seed, _write_3mf
|
|
|
|
|
+
|
|
|
|
|
+pytestmark = pytest.mark.asyncio
|
|
|
|
|
+
|
|
|
|
|
+USED = [{"slot_id": 1, "used_g": 42.5, "type": "PLA", "color": "#00AE42"}]
|
|
|
|
|
+REMAIN_AT_START = {"0:0": {"remain": 80, "tray_uuid": "A" * 32}}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _with(maker, *, spoolman: bool = True, row: dict | None = None, printer_id: int, archive_id: int) -> None:
|
|
|
|
|
+ async with maker() as db:
|
|
|
|
|
+ if spoolman:
|
|
|
|
|
+ db.add(Settings(key="spoolman_enabled", value="true"))
|
|
|
|
|
+ if row is not None:
|
|
|
|
|
+ db.add(ActivePrintSpoolman(printer_id=printer_id, archive_id=archive_id, ams_trays={}, **row))
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _recover(maker, tmp_path, printer_id: int, archive_id: int, *, price: bool = True) -> None:
|
|
|
|
|
+ from backend.app import main as main_module
|
|
|
|
|
+
|
|
|
|
|
+ source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
|
|
|
|
|
+ printer_manager = MagicMock()
|
|
|
|
|
+ printer_manager.get_status.return_value = SimpleNamespace(
|
|
|
|
|
+ raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 71, "tray_type": "PLA"}]}]}, tray_now=0
|
|
|
|
|
+ )
|
|
|
|
|
+ with (
|
|
|
|
|
+ patch.object(main_module, "async_session", maker),
|
|
|
|
|
+ patch.object(main_module, "printer_manager", printer_manager),
|
|
|
|
|
+ ):
|
|
|
|
|
+ await main_module._recover_fallback_archive_locked(archive_id, source, printer_id, price=price)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _row(maker, archive_id: int) -> ActivePrintSpoolman | None:
|
|
|
|
|
+ async with maker() as db:
|
|
|
|
|
+ return (
|
|
|
|
|
+ await db.execute(select(ActivePrintSpoolman).where(ActivePrintSpoolman.archive_id == archive_id))
|
|
|
|
|
+ ).scalar_one_or_none()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+async def seeded(test_engine, tmp_path):
|
|
|
|
|
+ from backend.app import main as main_module
|
|
|
|
|
+
|
|
|
|
|
+ maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
|
|
|
|
|
+ with patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True):
|
|
|
|
|
+ yield maker, printer_id, archive_id
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_the_recovered_usage_is_added_and_the_start_snapshot_kept(seeded, tmp_path):
|
|
|
|
|
+ maker, printer_id, archive_id = seeded
|
|
|
|
|
+ await _with(
|
|
|
|
|
+ maker,
|
|
|
|
|
+ printer_id=printer_id,
|
|
|
|
|
+ archive_id=archive_id,
|
|
|
|
|
+ row={"tray_remain_start": REMAIN_AT_START, "tray_now_at_start": 0, "slot_to_tray": [0]},
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ await _recover(maker, tmp_path, printer_id, archive_id)
|
|
|
|
|
+
|
|
|
|
|
+ row = await _row(maker, archive_id)
|
|
|
|
|
+ assert row.filament_usage == USED
|
|
|
|
|
+ # Taken at print start; the recovery is mid-print, so it must not replace them.
|
|
|
|
|
+ assert (row.tray_remain_start, row.tray_now_at_start, row.slot_to_tray, row.ams_trays) == (
|
|
|
|
|
+ REMAIN_AT_START,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ [0],
|
|
|
|
|
+ {},
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_so_does_the_recovery_at_the_print_s_end(seeded, tmp_path):
|
|
|
|
|
+ """It runs just before Spoolman is charged, so the charge reads it."""
|
|
|
|
|
+ maker, printer_id, archive_id = seeded
|
|
|
|
|
+ await _with(maker, printer_id=printer_id, archive_id=archive_id, row={"tray_remain_start": REMAIN_AT_START})
|
|
|
|
|
+
|
|
|
|
|
+ await _recover(maker, tmp_path, printer_id, archive_id, price=False)
|
|
|
|
|
+
|
|
|
|
|
+ assert (await _row(maker, archive_id)).filament_usage == USED
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_usage_already_tracked_is_left_alone(seeded, tmp_path):
|
|
|
|
|
+ maker, printer_id, archive_id = seeded
|
|
|
|
|
+ own = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FFFFFF"}]
|
|
|
|
|
+ await _with(maker, printer_id=printer_id, archive_id=archive_id, row={"filament_usage": own})
|
|
|
|
|
+
|
|
|
|
|
+ await _recover(maker, tmp_path, printer_id, archive_id)
|
|
|
|
|
+
|
|
|
|
|
+ assert (await _row(maker, archive_id)).filament_usage == own
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_a_print_tracked_with_nothing_at_its_start_is_tracked_now(seeded, tmp_path):
|
|
|
|
|
+ """No slot had a remain% at start, so nothing was stored and the print would
|
|
|
|
|
+ be charged nothing. The 3MF is all there is; no start snapshot is invented."""
|
|
|
|
|
+ maker, printer_id, archive_id = seeded
|
|
|
|
|
+ await _with(maker, printer_id=printer_id, archive_id=archive_id)
|
|
|
|
|
+
|
|
|
|
|
+ await _recover(maker, tmp_path, printer_id, archive_id)
|
|
|
|
|
+
|
|
|
|
|
+ row = await _row(maker, archive_id)
|
|
|
|
|
+ assert row.filament_usage == USED
|
|
|
|
|
+ assert row.tray_remain_start is None
|
|
|
|
|
+ assert row.tray_now_at_start is None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_the_recovery_at_the_print_s_end_tracks_it_too(seeded, tmp_path):
|
|
|
|
|
+ """It runs inside the close-out (marked as such), before the charge, so the
|
|
|
|
|
+ charge reads it."""
|
|
|
|
|
+ from backend.app.services import usage_tracker
|
|
|
|
|
+
|
|
|
|
|
+ maker, printer_id, archive_id = seeded
|
|
|
|
|
+ await _with(maker, printer_id=printer_id, archive_id=archive_id)
|
|
|
|
|
+ usage_tracker.mark_completing(printer_id)
|
|
|
|
|
+ try:
|
|
|
|
|
+ await _recover(maker, tmp_path, printer_id, archive_id, price=False)
|
|
|
|
|
+ finally:
|
|
|
|
|
+ usage_tracker.done_completing(printer_id)
|
|
|
|
|
+
|
|
|
|
|
+ assert (await _row(maker, archive_id)).filament_usage == USED
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_no_other_recovery_starts_tracking_while_the_print_is_closed(seeded, tmp_path):
|
|
|
|
|
+ """The cover endpoint's download can land while the print is being closed:
|
|
|
|
|
+ tracking made then could follow the charge that already deleted it, and be
|
|
|
|
|
+ left behind, making the printer look mid-print to later spool archiving."""
|
|
|
|
|
+ from backend.app.services import usage_tracker
|
|
|
|
|
+
|
|
|
|
|
+ maker, printer_id, archive_id = seeded
|
|
|
|
|
+ await _with(maker, printer_id=printer_id, archive_id=archive_id)
|
|
|
|
|
+ usage_tracker.mark_completing(printer_id)
|
|
|
|
|
+ try:
|
|
|
|
|
+ await _recover(maker, tmp_path, printer_id, archive_id)
|
|
|
|
|
+ finally:
|
|
|
|
|
+ usage_tracker.done_completing(printer_id)
|
|
|
|
|
+
|
|
|
|
|
+ assert await _row(maker, archive_id) is None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_nothing_without_spoolman(seeded, tmp_path):
|
|
|
|
|
+ maker, printer_id, archive_id = seeded
|
|
|
|
|
+ await _with(maker, spoolman=False, printer_id=printer_id, archive_id=archive_id)
|
|
|
|
|
+
|
|
|
|
|
+ await _recover(maker, tmp_path, printer_id, archive_id)
|
|
|
|
|
+
|
|
|
|
|
+ assert await _row(maker, archive_id) is None
|