| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803 |
- """Filament attribution has to survive a restart mid-print.
- A 14-hour print that spans a Bambuddy restart used to lose everything the
- completion path needs: the plate (so the 3MF parser summed every plate of a
- multi-plate file), the dispatched slot-to-tray mapping (so it fell back to the
- live MQTT ``mapping`` field, which AMS filament backup rewrites to the
- substitute tray), the spool-assignment snapshot, and the tray-change log that
- splits weight across a runout. The whole print was then charged to whichever
- spool happened to finish it, while the spool that actually ran dry was charged
- nothing.
- These tests cover the durable ``active_print_sessions`` row that fixes that,
- plus the plate and mapping fallbacks the completion path now applies.
- """
- from datetime import datetime, timezone
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from sqlalchemy import select
- from backend.app.models.active_print_session import ActivePrintSession
- from backend.app.models.printer import Printer
- from backend.app.services.usage_tracker import (
- PrintSession,
- _active_sessions,
- _track_from_3mf,
- clear_persisted_session,
- get_persisted_print_name,
- on_print_complete,
- persist_session,
- record_tray_change,
- restore_session,
- )
- def _make_spool(spool_id=1, label_weight=1000, weight_used=0):
- spool = MagicMock()
- spool.id = spool_id
- spool.label_weight = label_weight
- spool.weight_used = weight_used
- spool.tag_uid = None
- spool.tray_uuid = None
- spool.last_used = None
- spool.cost_per_kg = None
- spool.material = "ABS"
- spool.rgba = "616777FF"
- return spool
- def _make_assignment(spool_id=1, ams_id=0, tray_id=0):
- assignment = MagicMock()
- assignment.spool_id = spool_id
- assignment.printer_id = 1
- assignment.ams_id = ams_id
- assignment.tray_id = tray_id
- assignment.created_at = None
- return assignment
- def _make_archive(archive_id=1, plate_id=None, file_path="archives/1/multi_plate.3mf"):
- archive = MagicMock()
- archive.id = archive_id
- archive.file_path = file_path
- archive.plate_id = plate_id
- archive.extra_data = None
- return archive
- def _make_queue_item(item_id=629, ams_mapping=None, plate_id=None):
- item = MagicMock()
- item.id = item_id
- item.ams_mapping = ams_mapping
- item.plate_id = plate_id
- item.status = "printing"
- return item
- def _mock_db_sequential(responses):
- """Mock db whose execute() yields the given rows in order."""
- db = AsyncMock()
- call_count = [0]
- async def mock_execute(*args, **kwargs):
- idx = call_count[0]
- call_count[0] += 1
- result = MagicMock()
- value = responses[idx] if idx < len(responses) else None
- result.scalar_one_or_none.return_value = value
- result.scalars.return_value.first.return_value = value
- result.scalar.return_value = None
- return result
- db.execute = mock_execute
- return db
- def _patched_3mf(filament_usage, capture=None):
- """Patch the 3MF extract, optionally recording the plate_id it was given."""
- def _extract(path, plate_id=None):
- if capture is not None:
- capture.append(plate_id)
- return filament_usage
- return patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", side_effect=_extract)
- def _settings_patch():
- mock_settings = patch("backend.app.core.config.settings")
- return mock_settings
- class TestPersistedSessionRoundTrip:
- """The row is the only thing that outlives the process."""
- @pytest.fixture(autouse=True)
- def _clear_sessions(self):
- _active_sessions.clear()
- yield
- _active_sessions.clear()
- @pytest.fixture
- async def printer(self, db_session):
- row = Printer(name="H2D-1", ip_address="192.168.0.10", access_code="1234", serial_number="TESTSERIAL")
- db_session.add(row)
- await db_session.commit()
- return row
- def _session(self, printer_id):
- return PrintSession(
- printer_id=printer_id,
- print_name="AMS_Rack",
- started_at=datetime(2026, 8, 11, 9, 25, 6, tzinfo=timezone.utc),
- tray_remain_start={(0, 2): 84, (0, 3): 100},
- tray_now_at_start=2,
- spool_assignments={(0, 2): 69, (0, 3): 68},
- ams_mapping=[2],
- plate_id=1,
- )
- @pytest.mark.asyncio
- async def test_restore_rebuilds_the_session_and_returns_the_tray_log(self, db_session, printer):
- await persist_session(db_session, self._session(printer.id), [(2, 0)])
- _active_sessions.clear()
- log = await restore_session(db_session, printer.id)
- assert log == [[2, 0]]
- restored = _active_sessions[printer.id]
- assert restored.plate_id == 1
- assert restored.ams_mapping == [2]
- assert restored.tray_now_at_start == 2
- # Tuple keys survive the JSON round trip — the completion path indexes
- # the snapshot by (ams_id, tray_id).
- assert restored.spool_assignments == {(0, 2): 69, (0, 3): 68}
- assert restored.tray_remain_start == {(0, 2): 84, (0, 3): 100}
- assert restored.started_at.tzinfo is not None
- @pytest.mark.asyncio
- async def test_tray_changes_accumulate_in_order(self, db_session, printer):
- await persist_session(db_session, self._session(printer.id), [(2, 0)])
- # The runout sequence from the reported print: A3 empties, the AMS
- # parks, then filament backup brings A4 in.
- await record_tray_change(db_session, printer.id, 254, 670)
- await record_tray_change(db_session, printer.id, 3, 675)
- assert await restore_session(db_session, printer.id) == [[2, 0], [254, 670], [3, 675]]
- @pytest.mark.asyncio
- async def test_tray_change_without_a_session_is_a_noop(self, db_session, printer):
- await record_tray_change(db_session, printer.id, 3, 675)
- row = await db_session.get(ActivePrintSession, printer.id)
- assert row is None
- @pytest.mark.asyncio
- async def test_print_start_overwrites_a_row_left_by_a_missed_completion(self, db_session, printer):
- await persist_session(db_session, self._session(printer.id), [(2, 0), (3, 675)])
- second = self._session(printer.id)
- second.print_name = "Cover"
- second.plate_id = 2
- second.ams_mapping = [5]
- second.spool_assignments = {(1, 0): 60}
- await persist_session(db_session, second, [(5, 0)])
- rows = (await db_session.execute(select(ActivePrintSession))).scalars().all()
- assert len(rows) == 1
- log = await restore_session(db_session, printer.id)
- assert log == [[5, 0]]
- assert _active_sessions[printer.id].plate_id == 2
- assert _active_sessions[printer.id].spool_assignments == {(1, 0): 60}
- @pytest.mark.asyncio
- async def test_clear_removes_the_row(self, db_session, printer):
- await persist_session(db_session, self._session(printer.id), [(2, 0)])
- await clear_persisted_session(db_session, printer.id)
- assert await restore_session(db_session, printer.id) is None
- assert await get_persisted_print_name(db_session, printer.id) is None
- @pytest.mark.asyncio
- async def test_clear_is_safe_without_a_row(self, db_session, printer):
- await clear_persisted_session(db_session, printer.id)
- @pytest.mark.asyncio
- async def test_print_name_is_readable_for_the_identity_check(self, db_session, printer):
- await persist_session(db_session, self._session(printer.id), None)
- assert await get_persisted_print_name(db_session, printer.id) == "AMS_Rack"
- @pytest.mark.asyncio
- async def test_completion_falls_back_to_the_persisted_row(self, db_session, printer):
- """No in-memory session (the restart case): the plate, the mapping and
- the assignment snapshot must still reach the 3MF path."""
- await persist_session(db_session, self._session(printer.id), [(2, 0), (3, 675)])
- _active_sessions.clear()
- captured = {}
- async def _fake_track(*args, **kwargs):
- captured.update(kwargs)
- return []
- with (
- patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
- patch("backend.app.services.usage_tracker._track_from_3mf", side_effect=_fake_track),
- ):
- await on_print_complete(
- printer.id,
- {"status": "completed", "subtask_name": "AMS_Rack"},
- MagicMock(),
- db_session,
- archive_id=312,
- )
- assert captured["plate_id"] == 1
- assert captured["ams_mapping"] == [2]
- assert captured["tray_now_at_start"] == 2
- assert captured["spool_assignments"] == {(0, 2): 69, (0, 3): 68}
- class TestPlateIdRecovery:
- """Without the plate, the 3MF parser sums every plate in the file and the
- whole multi-plate total lands on one spool."""
- @pytest.mark.asyncio
- async def test_archive_plate_id_is_used_when_the_session_is_gone(self):
- archive = _make_archive(archive_id=312, plate_id=1)
- spool = _make_spool(spool_id=68)
- assignment = _make_assignment(spool_id=68, ams_id=0, tray_id=3)
- db = _mock_db_sequential([archive, None, assignment, spool])
- seen_plate_ids: list = []
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = SimpleNamespace(
- raw_data={"mapping": [3]},
- progress=100,
- layer_num=809,
- tray_now=255,
- tray_change_log=[],
- total_layers=809,
- )
- with (
- _settings_patch() as mock_settings,
- _patched_3mf([{"slot_id": 1, "used_g": 1122.44, "type": "ABS", "color": "#808080"}], seen_plate_ids),
- ):
- mock_settings.base_dir = MagicMock()
- mock_path = MagicMock()
- mock_path.exists.return_value = True
- mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
- await _track_from_3mf(
- printer_id=1,
- archive_id=312,
- status="completed",
- print_name="AMS_Rack",
- handled_trays=set(),
- printer_manager=printer_manager,
- db=db,
- plate_id=None,
- )
- assert seen_plate_ids == [1]
- @pytest.mark.asyncio
- async def test_queue_item_plate_id_is_used_when_the_archive_has_none(self):
- archive = _make_archive(archive_id=312, plate_id=None)
- queue_item = _make_queue_item(plate_id=2)
- spool = _make_spool(spool_id=68)
- assignment = _make_assignment(spool_id=68, ams_id=0, tray_id=3)
- # db: archive, the single queue lookup (plate + mapping share it),
- # then assignment and spool
- db = _mock_db_sequential([archive, queue_item, assignment, spool])
- seen_plate_ids: list = []
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = SimpleNamespace(
- raw_data={"mapping": [3]},
- progress=100,
- layer_num=361,
- tray_now=255,
- tray_change_log=[],
- total_layers=361,
- )
- with (
- _settings_patch() as mock_settings,
- _patched_3mf([{"slot_id": 1, "used_g": 318.82, "type": "ABS", "color": "#808080"}], seen_plate_ids),
- ):
- mock_settings.base_dir = MagicMock()
- mock_path = MagicMock()
- mock_path.exists.return_value = True
- mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
- await _track_from_3mf(
- printer_id=1,
- archive_id=312,
- status="completed",
- print_name="AMS_Rack",
- handled_trays=set(),
- printer_manager=printer_manager,
- db=db,
- plate_id=None,
- )
- assert seen_plate_ids == [2]
- @pytest.mark.asyncio
- async def test_caller_plate_id_wins_over_the_database(self):
- archive = _make_archive(archive_id=312, plate_id=1)
- spool = _make_spool(spool_id=68)
- assignment = _make_assignment(spool_id=68, ams_id=0, tray_id=3)
- db = _mock_db_sequential([archive, None, assignment, spool])
- seen_plate_ids: list = []
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = SimpleNamespace(
- raw_data={"mapping": [3]},
- progress=100,
- layer_num=361,
- tray_now=255,
- tray_change_log=[],
- total_layers=361,
- )
- with (
- _settings_patch() as mock_settings,
- _patched_3mf([{"slot_id": 1, "used_g": 318.82, "type": "ABS", "color": "#808080"}], seen_plate_ids),
- ):
- mock_settings.base_dir = MagicMock()
- mock_path = MagicMock()
- mock_path.exists.return_value = True
- mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
- await _track_from_3mf(
- printer_id=1,
- archive_id=312,
- status="completed",
- print_name="AMS_Rack",
- handled_trays=set(),
- printer_manager=printer_manager,
- db=db,
- plate_id=2,
- )
- assert seen_plate_ids == [2]
- class TestMappingPriority:
- """AMS filament backup rewrites the printer's live ``mapping`` field to the
- substitute tray. Read at completion it names the tray that finished the
- print, not the one the slicer assigned."""
- @pytest.mark.asyncio
- async def test_queue_mapping_beats_the_live_mqtt_mapping(self):
- archive = _make_archive(archive_id=312, plate_id=1)
- # Dispatched against AMS0-T2 (global tray 2); the printer now reports
- # tray 3 because backup swapped in the neighbouring spool.
- queue_item = _make_queue_item(ams_mapping="[2]")
- spool_69 = _make_spool(spool_id=69)
- assign_69 = _make_assignment(spool_id=69, ams_id=0, tray_id=2)
- db = _mock_db_sequential([archive, queue_item, assign_69, spool_69])
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = SimpleNamespace(
- raw_data={"mapping": [3]},
- progress=100,
- layer_num=809,
- tray_now=255,
- tray_change_log=[],
- total_layers=809,
- )
- with (
- _settings_patch() as mock_settings,
- _patched_3mf([{"slot_id": 1, "used_g": 1122.44, "type": "ABS", "color": "#808080"}]),
- ):
- mock_settings.base_dir = MagicMock()
- mock_path = MagicMock()
- mock_path.exists.return_value = True
- mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
- results = await _track_from_3mf(
- printer_id=1,
- archive_id=312,
- status="completed",
- print_name="AMS_Rack",
- handled_trays=set(),
- printer_manager=printer_manager,
- db=db,
- plate_id=1,
- )
- assert len(results) == 1
- assert results[0]["spool_id"] == 69
- assert (results[0]["ams_id"], results[0]["tray_id"]) == (0, 2)
- @pytest.mark.asyncio
- async def test_mqtt_mapping_still_used_for_a_direct_print(self):
- """No queue item — the live field is the only mapping there is."""
- archive = _make_archive(archive_id=400, plate_id=1)
- spool = _make_spool(spool_id=68)
- assignment = _make_assignment(spool_id=68, ams_id=0, tray_id=3)
- db = _mock_db_sequential([archive, None, assignment, spool])
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = SimpleNamespace(
- raw_data={"mapping": [3]},
- progress=100,
- layer_num=100,
- tray_now=255,
- tray_change_log=[],
- total_layers=100,
- )
- with (
- _settings_patch() as mock_settings,
- _patched_3mf([{"slot_id": 1, "used_g": 50.0, "type": "ABS", "color": "#808080"}]),
- ):
- mock_settings.base_dir = MagicMock()
- mock_path = MagicMock()
- mock_path.exists.return_value = True
- mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
- results = await _track_from_3mf(
- printer_id=1,
- archive_id=400,
- status="completed",
- print_name="Cover",
- handled_trays=set(),
- printer_manager=printer_manager,
- db=db,
- plate_id=1,
- )
- assert len(results) == 1
- assert (results[0]["ams_id"], results[0]["tray_id"]) == (0, 3)
- class TestRestoreOnRestartRecovery:
- """``on_print_running_observed`` is the only hook that fires when Bambuddy
- comes up mid-print — the #1304 guard suppresses ``on_print_start``."""
- @pytest.fixture(autouse=True)
- def _clear_sessions(self):
- _active_sessions.clear()
- yield
- _active_sessions.clear()
- @pytest.fixture
- async def printer(self, db_session):
- row = Printer(name="H2D-1", ip_address="192.168.0.10", access_code="1234", serial_number="TESTSERIAL")
- db_session.add(row)
- await db_session.commit()
- return row
- def _state(self, **overrides):
- state = SimpleNamespace(
- subtask_name="AMS_Rack",
- tray_change_log=[],
- tray_now=3,
- layer_num=700,
- last_loaded_tray=-1,
- )
- for key, value in overrides.items():
- setattr(state, key, value)
- return state
- def _session(self, printer_id, print_name="AMS_Rack"):
- return PrintSession(
- printer_id=printer_id,
- print_name=print_name,
- started_at=datetime(2026, 8, 11, 9, 25, 6, tzinfo=timezone.utc),
- tray_now_at_start=2,
- spool_assignments={(0, 2): 69},
- ams_mapping=[2],
- plate_id=1,
- )
- @pytest.mark.asyncio
- async def test_persisted_log_comes_back_onto_the_printer_state(self, db_session, printer):
- from backend.app.main import _restore_usage_tracking_session
- await persist_session(db_session, self._session(printer.id), [(2, 0)])
- _active_sessions.clear()
- state = self._state()
- await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
- assert state.tray_change_log == [(2, 0)]
- assert _active_sessions[printer.id].plate_id == 1
- @pytest.mark.asyncio
- async def test_entries_seen_by_this_process_are_kept_after_the_persisted_ones(self, db_session, printer):
- from backend.app.main import _restore_usage_tracking_session
- await persist_session(db_session, self._session(printer.id), [(2, 0)])
- state = self._state(tray_change_log=[(3, 675)])
- await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
- assert state.tray_change_log == [(2, 0), (3, 675)]
- @pytest.mark.asyncio
- async def test_no_persisted_row_seeds_from_the_tray_feeding_now(self, db_session, printer):
- """A print that started before this build still gets its remaining
- segment attributed to the right spool."""
- from backend.app.main import _restore_usage_tracking_session
- state = self._state()
- await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
- assert state.tray_change_log == [(3, 700)]
- assert state.last_loaded_tray == 3
- @pytest.mark.asyncio
- async def test_unloaded_tray_seeds_nothing(self, db_session, printer):
- from backend.app.main import _restore_usage_tracking_session
- state = self._state(tray_now=255)
- await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
- assert state.tray_change_log == []
- @pytest.mark.asyncio
- async def test_a_row_from_a_different_print_is_discarded(self, db_session, printer):
- """A completion Bambuddy never saw leaves a row behind; it must not
- attach itself to whatever is running now."""
- from backend.app.main import _restore_usage_tracking_session
- await persist_session(db_session, self._session(printer.id, print_name="Old_Print"), [(2, 0)])
- _active_sessions.clear()
- state = self._state(subtask_name="AMS_Rack")
- await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
- assert printer.id not in _active_sessions
- assert await restore_session(db_session, printer.id) is None
- # Still seeded, so the rest of the running print stays attributable.
- assert state.tray_change_log == [(3, 700)]
- @pytest.mark.asyncio
- async def test_an_unloaded_tray_does_not_clobber_last_loaded_tray(self, db_session, printer):
- """``last_loaded_tray`` is the fallback that survives the end-of-print
- retract to 255; writing 255 into it would defeat its whole purpose."""
- from backend.app.main import _restore_usage_tracking_session
- state = self._state(tray_now=255, last_loaded_tray=2)
- await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
- assert state.last_loaded_tray == 2
- @pytest.mark.asyncio
- async def test_a_failure_is_swallowed_so_the_caller_keeps_going(self, db_session, printer):
- """The caller still has to capture its timelapse baseline before the
- printer uploads the in-flight recording — there is no second chance."""
- from backend.app.main import _restore_usage_tracking_session
- broken = SimpleNamespace() # no subtask_name, no tray fields at all
- await _restore_usage_tracking_session(printer.id, broken, db_session, MagicMock())
- class TestPlateNotInTheFile:
- """A recovered plate has to be treated as a hint, not a filter that can
- silently zero out a print's usage."""
- @pytest.mark.asyncio
- async def test_falls_back_to_the_whole_file_when_the_plate_is_absent(self):
- """The archive's own 3MF can be gone, with a same-named library file
- substituted that was sliced with different plates."""
- archive = _make_archive(archive_id=312, plate_id=7)
- spool = _make_spool(spool_id=68)
- assignment = _make_assignment(spool_id=68, ams_id=0, tray_id=3)
- db = _mock_db_sequential([archive, None, assignment, spool])
- calls: list = []
- def _extract(path, plate_id=None):
- calls.append(plate_id)
- return [] if plate_id is not None else [{"slot_id": 1, "used_g": 12.0, "type": "ABS", "color": ""}]
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = SimpleNamespace(
- raw_data={"mapping": [3]},
- progress=100,
- layer_num=10,
- tray_now=255,
- tray_change_log=[],
- total_layers=10,
- )
- with (
- _settings_patch() as mock_settings,
- patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", side_effect=_extract),
- ):
- mock_settings.base_dir = MagicMock()
- mock_path = MagicMock()
- mock_path.exists.return_value = True
- mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
- results = await _track_from_3mf(
- printer_id=1,
- archive_id=312,
- status="completed",
- print_name="AMS_Rack",
- handled_trays=set(),
- printer_manager=printer_manager,
- db=db,
- plate_id=None,
- )
- assert calls == [7, None]
- assert len(results) == 1
- assert results[0]["weight_used"] == 12.0
- @pytest.mark.asyncio
- async def test_a_file_with_no_usage_at_all_still_records_nothing(self):
- archive = _make_archive(archive_id=312, plate_id=1)
- db = _mock_db_sequential([archive, None])
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = SimpleNamespace(
- raw_data={"mapping": [3]},
- progress=100,
- layer_num=10,
- tray_now=255,
- tray_change_log=[],
- total_layers=10,
- )
- with (
- _settings_patch() as mock_settings,
- patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=[]),
- ):
- mock_settings.base_dir = MagicMock()
- mock_path = MagicMock()
- mock_path.exists.return_value = True
- mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
- results = await _track_from_3mf(
- printer_id=1,
- archive_id=312,
- status="completed",
- print_name="AMS_Rack",
- handled_trays=set(),
- printer_manager=printer_manager,
- db=db,
- plate_id=1,
- )
- assert results == []
- class TestSpoolmanParity:
- """Both inventory backends need the same restart protection.
- Spoolman's own durable row (#1820) already carries its plate-scoped 3MF
- figures and the mapping it was dispatched with, but not the tray-change
- log — and that log is the only record of which spool fed which layers when
- AMS Filament Backup swaps trays. Capturing it for one backend only would
- leave Spoolman users with the bug this fixes for everyone else.
- """
- @pytest.fixture(autouse=True)
- def _clear_sessions(self):
- _active_sessions.clear()
- yield
- _active_sessions.clear()
- @pytest.fixture
- async def printer(self, db_session):
- row = Printer(name="H2D-1", ip_address="192.168.0.10", access_code="1234", serial_number="TESTSERIAL")
- db_session.add(row)
- await db_session.commit()
- return row
- def _printer_manager(self):
- pm = MagicMock()
- pm.get_status.return_value = SimpleNamespace(
- raw_data={
- "ams": {"ams": [{"id": 0, "tray": [{"id": 2, "remain": 84, "tray_type": "ABS"}]}]},
- "vt_tray": [],
- "mapping": [2],
- },
- tray_now=2,
- last_loaded_tray=2,
- tray_change_log=[(2, 0)],
- )
- return pm
- @pytest.mark.asyncio
- async def test_the_row_is_written_with_spoolman_enabled(self, db_session, printer):
- from backend.app.services.usage_tracker import on_print_start
- await on_print_start(
- printer.id,
- {"subtask_name": "AMS_Rack", "ams_mapping": [2]},
- self._printer_manager(),
- db=db_session,
- spoolman_owns_usage=True,
- )
- row = await db_session.get(ActivePrintSession, printer.id)
- assert row is not None
- assert row.print_name == "AMS_Rack"
- assert row.tray_change_log == [[2, 0]]
- @pytest.mark.asyncio
- async def test_spoolman_does_not_get_an_in_memory_session(self, db_session, printer):
- """``_active_sessions`` doubles as on_ams_change's "skip the remain%
- weight sync, the internal tracker will deduct precisely" flag (#880).
- A session the internal tracker will never complete would suppress a
- sync Spoolman users still need."""
- from backend.app.services.usage_tracker import on_print_start
- await on_print_start(
- printer.id,
- {"subtask_name": "AMS_Rack", "ams_mapping": [2]},
- self._printer_manager(),
- db=db_session,
- spoolman_owns_usage=True,
- )
- assert printer.id not in _active_sessions
- @pytest.mark.asyncio
- async def test_the_internal_tracker_still_gets_one(self, db_session, printer):
- from backend.app.services.usage_tracker import on_print_start
- await on_print_start(
- printer.id,
- {"subtask_name": "AMS_Rack", "ams_mapping": [2]},
- self._printer_manager(),
- db=db_session,
- spoolman_owns_usage=False,
- )
- assert _active_sessions[printer.id].ams_mapping == [2]
- assert await db_session.get(ActivePrintSession, printer.id) is not None
- @pytest.mark.asyncio
- async def test_restore_can_return_the_log_without_publishing_a_session(self, db_session, printer):
- await persist_session(
- db_session,
- PrintSession(
- printer_id=printer.id,
- print_name="AMS_Rack",
- started_at=datetime(2026, 8, 11, 9, 25, 6, tzinfo=timezone.utc),
- ),
- [(2, 0), (3, 675)],
- )
- _active_sessions.clear()
- log = await restore_session(db_session, printer.id, register_active=False)
- assert log == [[2, 0], [3, 675]]
- assert printer.id not in _active_sessions
- @pytest.mark.asyncio
- async def test_discard_clears_both_halves(self, db_session, printer):
- from backend.app.services.usage_tracker import discard_session
- session = PrintSession(
- printer_id=printer.id,
- print_name="AMS_Rack",
- started_at=datetime(2026, 8, 11, 9, 25, 6, tzinfo=timezone.utc),
- )
- _active_sessions[printer.id] = session
- await persist_session(db_session, session, [(2, 0)])
- await discard_session(db_session, printer.id)
- assert printer.id not in _active_sessions
- assert await db_session.get(ActivePrintSession, printer.id) is None
|