|
|
@@ -0,0 +1,379 @@
|
|
|
+"""A running print is priced from its spools at start, not only at completion (#3261).
|
|
|
+
|
|
|
+Before this, the archive card showed ``grams x default_filament_cost`` (or the
|
|
|
+catalogue rate) for the whole print and swapped in the spool-based figure only
|
|
|
+when the print finished. The estimate uses the same rules as the completion
|
|
|
+writers: each slot at the spool in its mapped tray, everything else at the
|
|
|
+default rate, and a reprint keeps its first run's cost.
|
|
|
+"""
|
|
|
+
|
|
|
+from types import SimpleNamespace
|
|
|
+from unittest.mock import AsyncMock, patch
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from backend.app.models.settings import Settings
|
|
|
+from backend.app.models.spool import Spool
|
|
|
+from backend.app.models.spool_assignment import SpoolAssignment
|
|
|
+from backend.app.services import archive_cost_estimate
|
|
|
+from backend.app.services.archive_cost_estimate import estimate_archive_cost_at_start
|
|
|
+
|
|
|
+# Reporter's archive 295: 4.17 g of PLA; their default rate is 3,400/kg and the
|
|
|
+# linked spool cost 1,870 for 1,000 g.
|
|
|
+DEFAULT_PER_KG = "3400"
|
|
|
+
|
|
|
+
|
|
|
+def _printer_manager(trays: list[dict] | None = None, vt_tray: list[dict] | None = None):
|
|
|
+ raw = {
|
|
|
+ "ams": [
|
|
|
+ {"id": 0, "tray": trays if trays is not None else [{"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF"}]}
|
|
|
+ ],
|
|
|
+ "vt_tray": vt_tray or [],
|
|
|
+ }
|
|
|
+ state = SimpleNamespace(raw_data=raw)
|
|
|
+ return SimpleNamespace(get_status=lambda _pid: state)
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture
|
|
|
+def three_mf(tmp_path, monkeypatch):
|
|
|
+ """Point base_dir at tmp_path, drop a 3MF there, and stub its per-slot usage."""
|
|
|
+ monkeypatch.setattr(archive_cost_estimate.app_settings, "base_dir", tmp_path)
|
|
|
+ (tmp_path / "archive").mkdir()
|
|
|
+ (tmp_path / "archive" / "print.3mf").write_bytes(b"3mf")
|
|
|
+ usage: list[dict] = []
|
|
|
+ monkeypatch.setattr(
|
|
|
+ "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
|
|
|
+ lambda _path, _plate: usage,
|
|
|
+ )
|
|
|
+ return usage
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture
|
|
|
+def ws():
|
|
|
+ with patch("backend.app.core.websocket.ws_manager.send_archive_updated", new=AsyncMock()) as sent:
|
|
|
+ yield sent
|
|
|
+
|
|
|
+
|
|
|
+async def _setup(db_session, printer_factory, archive_factory, *, spoolman=False, with_run=False, grams=4.17):
|
|
|
+ db_session.add(Settings(key="default_filament_cost", value=DEFAULT_PER_KG))
|
|
|
+ if spoolman:
|
|
|
+ db_session.add(Settings(key="spoolman_enabled", value="true"))
|
|
|
+ printer = await printer_factory()
|
|
|
+ archive = await archive_factory(
|
|
|
+ printer.id,
|
|
|
+ print_name="Archive295",
|
|
|
+ status="printing",
|
|
|
+ cost=14.18,
|
|
|
+ with_run=with_run,
|
|
|
+ file_path="archive/print.3mf",
|
|
|
+ filament_used_grams=grams,
|
|
|
+ filament_type="PLA",
|
|
|
+ )
|
|
|
+ await db_session.commit()
|
|
|
+ return printer, archive
|
|
|
+
|
|
|
+
|
|
|
+async def _assign_spool(db_session, printer_id: int, ams_id: int, tray_id: int, cost_per_kg: float | None):
|
|
|
+ spool = Spool(material="PLA", cost_per_kg=cost_per_kg)
|
|
|
+ db_session.add(spool)
|
|
|
+ await db_session.flush()
|
|
|
+ db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer_id, ams_id=ams_id, tray_id=tray_id))
|
|
|
+ await db_session.commit()
|
|
|
+
|
|
|
+
|
|
|
+class TestInternalInventory:
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_priced_from_the_assigned_spool(self, db_session, printer_factory, archive_factory, three_mf, ws):
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory)
|
|
|
+ await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
|
|
|
+ three_mf.append({"slot_id": 1, "used_g": 4.17, "color": "#FFFFFF"})
|
|
|
+
|
|
|
+ await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 7.80
|
|
|
+ ws.assert_awaited_once_with({"id": archive.id, "cost": 7.80})
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_unpriced_grams_at_the_default_rate(self, db_session, printer_factory, archive_factory, three_mf, ws):
|
|
|
+ """Slot 2 maps to a tray with no spool; its grams, and grams the 3MF
|
|
|
+ didn't attribute, are covered at the default rate like completion does."""
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory, grams=12.0)
|
|
|
+ await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1000)
|
|
|
+ three_mf.extend([{"slot_id": 1, "used_g": 5.0}, {"slot_id": 2, "used_g": 5.0}])
|
|
|
+
|
|
|
+ await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0, 1])
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ # 5 g at 1.0/g + 7 g at 3.4/g
|
|
|
+ assert archive.cost == 28.80
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_spool_without_price_uses_the_default_rate(
|
|
|
+ self, db_session, printer_factory, archive_factory, three_mf, ws
|
|
|
+ ):
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory, grams=10.0)
|
|
|
+ await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=None)
|
|
|
+ three_mf.append({"slot_id": 1, "used_g": 10.0})
|
|
|
+
|
|
|
+ await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 34.00
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_queue_item_mapping_is_used(self, db_session, printer_factory, archive_factory, three_mf, ws):
|
|
|
+ from backend.app.models.print_queue import PrintQueueItem
|
|
|
+
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory)
|
|
|
+ await _assign_spool(db_session, printer.id, 0, 2, cost_per_kg=1870)
|
|
|
+ db_session.add(
|
|
|
+ PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", ams_mapping="[2]")
|
|
|
+ )
|
|
|
+ await db_session.commit()
|
|
|
+ three_mf.append({"slot_id": 1, "used_g": 4.17})
|
|
|
+ trays = [{"id": i, "tray_type": "PLA", "tray_color": "FFFFFFFF"} for i in range(4)]
|
|
|
+
|
|
|
+ await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(trays))
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 7.80
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_external_spool_mapped_as_minus_one(self, db_session, printer_factory, archive_factory, three_mf, ws):
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory)
|
|
|
+ await _assign_spool(db_session, printer.id, 255, 0, cost_per_kg=1870)
|
|
|
+ three_mf.append({"slot_id": 1, "used_g": 4.17})
|
|
|
+ pm = _printer_manager(vt_tray=[{"id": 254, "tray_type": "PLA"}])
|
|
|
+
|
|
|
+ await estimate_archive_cost_at_start(db_session, printer.id, archive.id, pm, ams_mapping=[-1])
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 7.80
|
|
|
+
|
|
|
+
|
|
|
+class TestLeavesThePlaceholder:
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_reprint_keeps_the_first_runs_cost(self, db_session, printer_factory, archive_factory, three_mf, ws):
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory, with_run=True)
|
|
|
+ await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
|
|
|
+ three_mf.append({"slot_id": 1, "used_g": 4.17})
|
|
|
+
|
|
|
+ await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 14.18
|
|
|
+ ws.assert_not_awaited()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_no_trustworthy_mapping(self, db_session, printer_factory, archive_factory, three_mf, ws):
|
|
|
+ """No print-command or queue mapping, and the colours don't match a tray:
|
|
|
+ no positional guess, the placeholder stays."""
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory)
|
|
|
+ await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
|
|
|
+ three_mf.append({"slot_id": 1, "used_g": 4.17, "color": "#123456"})
|
|
|
+
|
|
|
+ await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager())
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 14.18
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_colour_match_when_no_mapping(self, db_session, printer_factory, archive_factory, three_mf, ws):
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory)
|
|
|
+ await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
|
|
|
+ three_mf.append({"slot_id": 1, "used_g": 4.17, "color": "#FFFFFF", "type": "PLA"})
|
|
|
+
|
|
|
+ await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager())
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 7.80
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_no_spool_in_any_mapped_tray(self, db_session, printer_factory, archive_factory, three_mf, ws):
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory)
|
|
|
+ three_mf.append({"slot_id": 1, "used_g": 4.17})
|
|
|
+
|
|
|
+ await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 14.18
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_missing_3mf(self, db_session, printer_factory, archive_factory, three_mf, ws, tmp_path):
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory)
|
|
|
+ await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
|
|
|
+ (tmp_path / "archive" / "print.3mf").unlink()
|
|
|
+
|
|
|
+ await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 14.18
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_an_error_never_escapes(
|
|
|
+ self, db_session, printer_factory, archive_factory, three_mf, ws, monkeypatch
|
|
|
+ ):
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory)
|
|
|
+ three_mf.append({"slot_id": 1, "used_g": 4.17})
|
|
|
+
|
|
|
+ def boom(_pid):
|
|
|
+ raise RuntimeError("printer state unavailable")
|
|
|
+
|
|
|
+ pm = SimpleNamespace(get_status=boom)
|
|
|
+
|
|
|
+ await estimate_archive_cost_at_start(db_session, printer.id, archive.id, pm, ams_mapping=[0])
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 14.18
|
|
|
+
|
|
|
+
|
|
|
+class TestSpoolman:
|
|
|
+ @staticmethod
|
|
|
+ def _client(spools_by_tag: dict | None = None, spools_by_id: dict | None = None):
|
|
|
+ client = SimpleNamespace()
|
|
|
+ client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: (spools_by_tag or {}).get(tag))
|
|
|
+ client.get_spool = AsyncMock(side_effect=lambda sid: (spools_by_id or {})[sid])
|
|
|
+ return client
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_priced_from_the_slot_assigned_spool(
|
|
|
+ self, db_session, printer_factory, archive_factory, three_mf, ws
|
|
|
+ ):
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True)
|
|
|
+ three_mf.append({"slot_id": 1, "used_g": 4.17})
|
|
|
+ spool = {"id": 7, "price": 1870, "initial_weight": 1000, "filament": {"weight": 1000}}
|
|
|
+ client = self._client(spools_by_id={7: spool})
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch(
|
|
|
+ "backend.app.services.spoolman.get_spoolman_client",
|
|
|
+ new=AsyncMock(return_value=client),
|
|
|
+ ),
|
|
|
+ patch("backend.app.services.spoolman_tracking._get_printer_serial", new=AsyncMock(return_value="")),
|
|
|
+ patch(
|
|
|
+ "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
|
|
|
+ new=AsyncMock(return_value=7),
|
|
|
+ ),
|
|
|
+ ):
|
|
|
+ await estimate_archive_cost_at_start(
|
|
|
+ db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0]
|
|
|
+ )
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 7.80
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_spoolman_unreachable_keeps_the_placeholder(
|
|
|
+ self, db_session, printer_factory, archive_factory, three_mf, ws
|
|
|
+ ):
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True)
|
|
|
+ three_mf.append({"slot_id": 1, "used_g": 4.17})
|
|
|
+
|
|
|
+ with patch(
|
|
|
+ "backend.app.services.spoolman.get_spoolman_client",
|
|
|
+ new=AsyncMock(return_value=None),
|
|
|
+ ):
|
|
|
+ await estimate_archive_cost_at_start(
|
|
|
+ db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0]
|
|
|
+ )
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 14.18
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_failing_spool_fetch_leaves_only_that_slot_unpriced(
|
|
|
+ self, db_session, printer_factory, archive_factory, three_mf, ws
|
|
|
+ ):
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True, grams=10.0)
|
|
|
+ three_mf.extend([{"slot_id": 1, "used_g": 5.0}, {"slot_id": 2, "used_g": 5.0}])
|
|
|
+ client = self._client(spools_by_id={7: {"id": 7, "price": 1000, "initial_weight": 1000}})
|
|
|
+ trays = [{"id": i, "tray_type": "PLA"} for i in range(2)]
|
|
|
+
|
|
|
+ async def slot_assignment(_pid, _ams, tray_id):
|
|
|
+ return 7 if tray_id == 0 else 8 # spool 8 isn't in Spoolman: get_spool raises
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch(
|
|
|
+ "backend.app.services.spoolman.get_spoolman_client",
|
|
|
+ new=AsyncMock(return_value=client),
|
|
|
+ ),
|
|
|
+ patch("backend.app.services.spoolman_tracking._get_printer_serial", new=AsyncMock(return_value="")),
|
|
|
+ patch(
|
|
|
+ "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
|
|
|
+ new=slot_assignment,
|
|
|
+ ),
|
|
|
+ ):
|
|
|
+ await estimate_archive_cost_at_start(
|
|
|
+ db_session, printer.id, archive.id, _printer_manager(trays), ams_mapping=[0, 1]
|
|
|
+ )
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ # 5 g at 1.0/g + 5 g at the 3.4/g default
|
|
|
+ assert archive.cost == 22.00
|
|
|
+
|
|
|
+
|
|
|
+class TestScheduling:
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_runs_in_the_background_on_its_own_session(self):
|
|
|
+ """Print start doesn't wait for the estimate; it gets its own session and
|
|
|
+ a copy of the mapping, so later changes to the caller's list don't leak in."""
|
|
|
+ from contextlib import asynccontextmanager
|
|
|
+
|
|
|
+ sentinel_db = object()
|
|
|
+
|
|
|
+ @asynccontextmanager
|
|
|
+ async def fake_session():
|
|
|
+ yield sentinel_db
|
|
|
+
|
|
|
+ mapping = [0, 1]
|
|
|
+ with (
|
|
|
+ patch("backend.app.core.database.async_session", new=fake_session),
|
|
|
+ patch.object(archive_cost_estimate, "estimate_archive_cost_at_start", new=AsyncMock()) as estimate,
|
|
|
+ ):
|
|
|
+ task = archive_cost_estimate.schedule_archive_cost_estimate(3, 42, "pm", ams_mapping=mapping, plate_id=2)
|
|
|
+ mapping.append(9)
|
|
|
+ await task
|
|
|
+
|
|
|
+ estimate.assert_awaited_once_with(sentinel_db, 3, 42, "pm", [0, 1], 2)
|
|
|
+
|
|
|
+
|
|
|
+class TestRaceWithCompletion:
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_print_that_ended_during_the_lookup_keeps_the_final_cost(
|
|
|
+ self, db_session, printer_factory, archive_factory, three_mf, ws
|
|
|
+ ):
|
|
|
+ """A print that fails seconds in can complete while Spoolman is still
|
|
|
+ answering; the figure completion wrote must survive."""
|
|
|
+ printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True)
|
|
|
+ three_mf.append({"slot_id": 1, "used_g": 4.17})
|
|
|
+ archive_id = archive.id
|
|
|
+
|
|
|
+ async def slow_spool(_sid):
|
|
|
+ # Completion lands while the estimate waits on Spoolman.
|
|
|
+ from sqlalchemy import update
|
|
|
+
|
|
|
+ from backend.app.models.archive import PrintArchive
|
|
|
+
|
|
|
+ await db_session.execute(
|
|
|
+ update(PrintArchive).where(PrintArchive.id == archive_id).values(status="failed", cost=1.23)
|
|
|
+ )
|
|
|
+ await db_session.commit()
|
|
|
+ return {"id": 7, "price": 1870, "initial_weight": 1000}
|
|
|
+
|
|
|
+ client = SimpleNamespace(find_spool_by_tag=AsyncMock(return_value=None), get_spool=slow_spool)
|
|
|
+ with (
|
|
|
+ patch("backend.app.services.spoolman.get_spoolman_client", new=AsyncMock(return_value=client)),
|
|
|
+ patch("backend.app.services.spoolman_tracking._get_printer_serial", new=AsyncMock(return_value="")),
|
|
|
+ patch(
|
|
|
+ "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
|
|
|
+ new=AsyncMock(return_value=7),
|
|
|
+ ),
|
|
|
+ ):
|
|
|
+ await estimate_archive_cost_at_start(
|
|
|
+ db_session, printer.id, archive_id, _printer_manager(), ams_mapping=[0]
|
|
|
+ )
|
|
|
+
|
|
|
+ await db_session.refresh(archive)
|
|
|
+ assert archive.cost == 1.23
|
|
|
+ ws.assert_not_awaited()
|