| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231 |
- """The tracker adds a print's usage to what is stored, not to its own copy (WU-4).
- Completion loads the spool, awaits more lookups, and only then charges it. A
- weight set by hand or weighed on the SpoolBuddy scale in between was lost:
- the tracker wrote back its earlier copy plus the print's grams. Each test
- stores 300 g used (the hand edit) while the tracker's copy still reads the
- 100 g it loaded before that edit.
- """
- from datetime import datetime, timezone
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from sqlalchemy import event, select
- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
- from sqlalchemy.orm.attributes import set_committed_value
- from backend.app.models.spool import Spool
- from backend.app.services.usage_tracker import (
- PrintSession,
- _active_sessions,
- _charge_spool,
- _track_from_3mf,
- on_print_complete,
- )
- pytestmark = pytest.mark.asyncio
- HAND_SET = 300.0
- def _loaded_before_the_hand_edit(spool, _context):
- set_committed_value(spool, "weight_used", 100.0)
- @pytest.fixture
- def stale_loads():
- event.listen(Spool, "load", _loaded_before_the_hand_edit)
- yield
- event.remove(Spool, "load", _loaded_before_the_hand_edit)
- @pytest.fixture
- async def maker(test_engine):
- return async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- async def _spools(maker, count: int = 1) -> list[int]:
- async with maker() as db:
- rows = [Spool(material="PLA", label_weight=1000, weight_used=HAND_SET) for _ in range(count)]
- db.add_all(rows)
- await db.commit()
- return [row.id for row in rows]
- async def _printer(maker) -> int:
- from backend.app.models.printer import Printer
- async with maker() as db:
- printer = Printer(name="P", serial_number="RACE01", ip_address="10.0.0.9", access_code="12345678")
- db.add(printer)
- await db.commit()
- return printer.id
- async def _stored(maker, spool_id: int) -> float:
- """Read as a column, so the stale-load hook does not touch it."""
- async with maker() as db:
- return await db.scalar(select(Spool.weight_used).where(Spool.id == spool_id))
- async def test_the_charge_adds_to_the_stored_weight(maker, stale_loads):
- (spool_id,) = await _spools(maker)
- async with maker() as db:
- spool = await db.get(Spool, spool_id)
- assert spool.weight_used == 100.0, "the copy is the stale one"
- await _charge_spool(db, spool, 25.0)
- assert spool.weight_used == HAND_SET + 25.0, "the copy follows what is stored"
- await db.commit()
- assert await _stored(maker, spool_id) == HAND_SET + 25.0
- async def test_a_spool_deleted_meanwhile_is_left_alone(maker):
- (spool_id,) = await _spools(maker)
- async with maker() as db:
- spool = await db.get(Spool, spool_id)
- async with maker() as other:
- await other.delete(await other.get(Spool, spool_id))
- await other.commit()
- assert await _charge_spool(db, spool, 25.0) is False
- await db.commit()
- async def _track_3mf(
- maker, tmp_path, spool_ids: dict[tuple[int, int], int], slots: int = 1, mapping=(0,), **state
- ) -> None:
- printer_id = await _printer(maker)
- threemf = tmp_path / "part.3mf"
- threemf.write_bytes(b"3mf")
- status = SimpleNamespace(
- progress=100, layer_num=90, tray_now=0, last_loaded_tray=-1, raw_data={}, **{"total_layers": 0, **state}
- )
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = status
- async def resolve(printer_id, ams_id, tray_id, **kwargs):
- return spool_ids.get((ams_id, tray_id))
- usage = [{"slot_id": n, "used_g": 9.0, "type": "PLA", "color": "#FFFFFF"} for n in range(1, slots + 1)]
- with (
- patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=usage),
- patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
- patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
- patch("backend.app.services.usage_tracker._resolve_spool_id_for_tray", side_effect=resolve),
- ):
- async with maker() as db:
- await _track_from_3mf(
- printer_id=printer_id,
- archive_id=None,
- status="completed",
- print_name="part",
- handled_trays=set(),
- printer_manager=printer_manager,
- db=db,
- ams_mapping=list(mapping),
- print_started_at=datetime.now(timezone.utc),
- threemf_path=threemf,
- )
- await db.commit()
- async def test_the_3mf_charge(maker, tmp_path, stale_loads):
- (spool_id,) = await _spools(maker)
- await _track_3mf(maker, tmp_path, {(0, 0): spool_id}, tray_change_log=[])
- assert await _stored(maker, spool_id) == HAND_SET + 9.0
- async def test_the_3mf_charge_split_across_trays(maker, tmp_path, stale_loads):
- first, second = await _spools(maker, 2)
- await _track_3mf(
- maker, tmp_path, {(0, 0): first, (0, 1): second}, tray_change_log=[(0, 0), (1, 45)], total_layers=90
- )
- assert (await _stored(maker, first), await _stored(maker, second)) == (HAND_SET + 4.5, HAND_SET + 4.5)
- @pytest.mark.parametrize("deleted", [False, True], ids=["charged", "deleted-meanwhile"])
- async def test_the_remain_percentage_charge(maker, stale_loads, deleted):
- from backend.app.models.spool_assignment import SpoolAssignment
- (spool_id,) = await _spools(maker)
- printer_id = await _printer(maker)
- async with maker() as db:
- db.add(SpoolAssignment(spool_id=spool_id, printer_id=printer_id, ams_id=0, tray_id=0))
- await db.commit()
- # 80% -> 70% of a 1000 g spool: 100 g.
- _active_sessions[printer_id] = PrintSession(
- printer_id=printer_id, print_name="part", started_at=datetime.now(timezone.utc), tray_remain_start={(0, 0): 80}
- )
- status = SimpleNamespace(
- raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, progress=100, layer_num=0, tray_now=255
- )
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = status
- try:
- with (
- patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
- patch("backend.app.services.usage_tracker._track_from_3mf", AsyncMock(return_value=[])),
- patch(
- "backend.app.services.usage_tracker._charge_spool",
- _deleting(maker, spool_id if deleted else -1),
- ),
- ):
- async with maker() as db:
- await on_print_complete(printer_id, {"status": "completed"}, printer_manager, db)
- finally:
- _active_sessions.pop(printer_id, None)
- if deleted:
- assert await _charged(maker) == []
- else:
- assert await _stored(maker, spool_id) == HAND_SET + 100.0
- @pytest.mark.parametrize("split", [True, False], ids=["split-across-trays", "one-tray-per-slot"])
- async def test_a_spool_deleted_while_the_print_is_charged_is_skipped(maker, tmp_path, split):
- """The rest of the print is still charged: a usage row for the deleted spool
- broke the foreign key on PostgreSQL and rolled the whole completion back."""
- first, second = await _spools(maker, 2)
- with patch("backend.app.services.usage_tracker._charge_spool", _deleting(maker, first)):
- if split:
- await _track_3mf(
- maker, tmp_path, {(0, 0): first, (0, 1): second}, tray_change_log=[(0, 0), (1, 45)], total_layers=90
- )
- else:
- await _track_3mf(maker, tmp_path, {(0, 0): first, (0, 1): second}, slots=2, mapping=(0, 1))
- assert await _stored(maker, second) == HAND_SET + (4.5 if split else 9.0)
- assert await _charged(maker) == [second]
- def _deleting(maker, spool_id: int):
- """_charge_spool, with *spool_id* deleted by someone else just before."""
- from backend.app.services import usage_tracker
- real_charge = usage_tracker._charge_spool
- async def charge(db, spool, grams):
- if spool.id == spool_id:
- async with maker() as other:
- await other.delete(await other.get(Spool, spool_id))
- await other.commit()
- return await real_charge(db, spool, grams)
- return charge
- async def _charged(maker) -> list[int]:
- from backend.app.models.spool_usage_history import SpoolUsageHistory
- async with maker() as db:
- return list((await db.execute(select(SpoolUsageHistory.spool_id))).scalars().all())
|