| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- """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()
|