|
@@ -0,0 +1,341 @@
|
|
|
|
|
+"""Tests for expected print promotion when auto_archive is disabled (#839).
|
|
|
|
|
+
|
|
|
|
|
+When auto_archive=False but a print was dispatched by BamBuddy (queue/reprint),
|
|
|
|
|
+the on_print_start callback must still promote the expected print to _active_prints
|
|
|
|
|
+so that at print completion the archive_id and ams_mapping are available for
|
|
|
|
|
+filament usage tracking.
|
|
|
|
|
+
|
|
|
|
|
+These are pure unit tests that verify the module-level dict manipulation logic
|
|
|
|
|
+directly, NOT by calling the full on_print_start callback.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+import time
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+
|
|
|
|
|
+from backend.app.main import (
|
|
|
|
|
+ _active_prints,
|
|
|
|
|
+ _expected_print_creators,
|
|
|
|
|
+ _expected_print_registered_at,
|
|
|
|
|
+ _expected_prints,
|
|
|
|
|
+ _print_ams_mappings,
|
|
|
|
|
+ register_expected_print,
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture(autouse=True)
|
|
|
|
|
+def _clear_dicts():
|
|
|
|
|
+ """Clear module-level tracking dicts before and after each test."""
|
|
|
|
|
+ _expected_prints.clear()
|
|
|
|
|
+ _expected_print_registered_at.clear()
|
|
|
|
|
+ _expected_print_creators.clear()
|
|
|
|
|
+ _print_ams_mappings.clear()
|
|
|
|
|
+ _active_prints.clear()
|
|
|
|
|
+ yield
|
|
|
|
|
+ _expected_prints.clear()
|
|
|
|
|
+ _expected_print_registered_at.clear()
|
|
|
|
|
+ _expected_print_creators.clear()
|
|
|
|
|
+ _print_ams_mappings.clear()
|
|
|
|
|
+ _active_prints.clear()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestRegisterExpectedPrint:
|
|
|
|
|
+ """Verify register_expected_print populates all tracking dicts."""
|
|
|
|
|
+
|
|
|
|
|
+ def test_registers_filename_and_variants(self):
|
|
|
|
|
+ register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+
|
|
|
|
|
+ assert _expected_prints[(1, "Box.3mf")] == 54
|
|
|
|
|
+ assert _expected_prints[(1, "Box")] == 54
|
|
|
|
|
+ assert _expected_prints[(1, "Box.gcode")] == 54
|
|
|
|
|
+
|
|
|
|
|
+ def test_stores_ams_mapping(self):
|
|
|
|
|
+ register_expected_print(1, "test.3mf", archive_id=10, ams_mapping=[2, -1, 3])
|
|
|
|
|
+ assert _print_ams_mappings[10] == [2, -1, 3]
|
|
|
|
|
+
|
|
|
|
|
+ def test_no_ams_mapping_when_none(self):
|
|
|
|
|
+ register_expected_print(1, "test.3mf", archive_id=10, ams_mapping=None)
|
|
|
|
|
+ assert 10 not in _print_ams_mappings
|
|
|
|
|
+
|
|
|
|
|
+ def test_stores_creator(self):
|
|
|
|
|
+ register_expected_print(1, "test.3mf", archive_id=10, created_by_id=5)
|
|
|
|
|
+ assert _expected_print_creators[(1, "test.3mf")] == 5
|
|
|
|
|
+
|
|
|
|
|
+ def test_stores_registered_at(self):
|
|
|
|
|
+ before = time.monotonic()
|
|
|
|
|
+ register_expected_print(1, "test.3mf", archive_id=10)
|
|
|
|
|
+ after = time.monotonic()
|
|
|
|
|
+
|
|
|
|
|
+ ts = _expected_print_registered_at[(1, "test.3mf")]
|
|
|
|
|
+ assert before <= ts <= after
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestExpectedPrintDetection:
|
|
|
|
|
+ """Verify the expected-print detection logic used in on_print_start.
|
|
|
|
|
+
|
|
|
|
|
+ Reproduces the key-building and lookup logic from the auto_archive=False
|
|
|
|
|
+ block in on_print_start to verify that expected prints are correctly
|
|
|
|
|
+ detected across all filename variations.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ @staticmethod
|
|
|
|
|
+ def _build_check_keys(printer_id: int, filename: str, subtask_name: str):
|
|
|
|
|
+ """Reproduce the key-building logic from on_print_start."""
|
|
|
|
|
+ check_keys = []
|
|
|
|
|
+ if subtask_name:
|
|
|
|
|
+ check_keys += [
|
|
|
|
|
+ (printer_id, subtask_name),
|
|
|
|
|
+ (printer_id, f"{subtask_name}.3mf"),
|
|
|
|
|
+ (printer_id, f"{subtask_name}.gcode.3mf"),
|
|
|
|
|
+ ]
|
|
|
|
|
+ if filename:
|
|
|
|
|
+ base_fn = filename.split("/")[-1] if "/" in filename else filename
|
|
|
|
|
+ check_keys.append((printer_id, base_fn))
|
|
|
|
|
+ no_archive_base = base_fn.replace(".gcode", "").replace(".3mf", "")
|
|
|
|
|
+ check_keys += [
|
|
|
|
|
+ (printer_id, no_archive_base),
|
|
|
|
|
+ (printer_id, f"{no_archive_base}.3mf"),
|
|
|
|
|
+ ]
|
|
|
|
|
+ return check_keys
|
|
|
|
|
+
|
|
|
|
|
+ def test_detects_expected_print_by_subtask(self):
|
|
|
|
|
+ """Expected print is found when subtask_name matches."""
|
|
|
|
|
+ register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+ keys = self._build_check_keys(1, filename="", subtask_name="Box")
|
|
|
|
|
+ assert any(k in _expected_prints for k in keys)
|
|
|
|
|
+
|
|
|
|
|
+ def test_detects_expected_print_by_filename(self):
|
|
|
|
|
+ """Expected print is found when filename matches."""
|
|
|
|
|
+ register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+ keys = self._build_check_keys(1, filename="Box.3mf", subtask_name="")
|
|
|
|
|
+ assert any(k in _expected_prints for k in keys)
|
|
|
|
|
+
|
|
|
|
|
+ def test_detects_expected_print_by_gcode_filename(self):
|
|
|
|
|
+ """Expected print is found when MQTT reports .gcode filename."""
|
|
|
|
|
+ register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+ # MQTT sometimes reports gcode filename
|
|
|
|
|
+ keys = self._build_check_keys(1, filename="Box.gcode", subtask_name="Box")
|
|
|
|
|
+ assert any(k in _expected_prints for k in keys)
|
|
|
|
|
+
|
|
|
|
|
+ def test_no_false_positive_for_different_file(self):
|
|
|
|
|
+ """Expected print NOT found for a different filename."""
|
|
|
|
|
+ register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+ keys = self._build_check_keys(1, filename="Benchy.3mf", subtask_name="Benchy")
|
|
|
|
|
+ assert not any(k in _expected_prints for k in keys)
|
|
|
|
|
+
|
|
|
|
|
+ def test_no_false_positive_for_different_printer(self):
|
|
|
|
|
+ """Expected print NOT found when printer_id doesn't match."""
|
|
|
|
|
+ register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+ keys = self._build_check_keys(2, filename="Box.3mf", subtask_name="Box")
|
|
|
|
|
+ assert not any(k in _expected_prints for k in keys)
|
|
|
|
|
+
|
|
|
|
|
+ def test_empty_expected_prints_returns_false(self):
|
|
|
|
|
+ """No detection when _expected_prints is empty."""
|
|
|
|
|
+ keys = self._build_check_keys(1, filename="test.3mf", subtask_name="test")
|
|
|
|
|
+ assert not any(k in _expected_prints for k in keys)
|
|
|
|
|
+
|
|
|
|
|
+ def test_filename_with_spaces_and_parens(self):
|
|
|
|
|
+ """Handles filenames with spaces and parentheses (e.g. 'Box3.0_(2)_plate_5.3mf')."""
|
|
|
|
|
+ register_expected_print(1, "Box3.0_(2)_plate_5.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+ keys = self._build_check_keys(
|
|
|
|
|
+ 1,
|
|
|
|
|
+ filename="Box3.0_(2)_plate_5.gcode",
|
|
|
|
|
+ subtask_name="Box3.0_(2)_plate_5",
|
|
|
|
|
+ )
|
|
|
|
|
+ assert any(k in _expected_prints for k in keys)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestExpectedPrintPromotion:
|
|
|
|
|
+ """Verify that expected prints are correctly promoted to _active_prints.
|
|
|
|
|
+
|
|
|
|
|
+ Reproduces the expected-print pop + promotion logic from on_print_start
|
|
|
|
|
+ (lines 1468-1496) to verify that _active_prints is populated and
|
|
|
|
|
+ _expected_prints is cleaned up.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ @staticmethod
|
|
|
|
|
+ def _simulate_expected_print_promotion(printer_id: int, subtask_name: str, filename: str, archive_filename: str):
|
|
|
|
|
+ """Simulate the expected-print lookup and promotion from on_print_start."""
|
|
|
|
|
+ expected_keys = []
|
|
|
|
|
+ if subtask_name:
|
|
|
|
|
+ expected_keys.append((printer_id, subtask_name))
|
|
|
|
|
+ expected_keys.append((printer_id, f"{subtask_name}.3mf"))
|
|
|
|
|
+ expected_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
|
|
|
|
|
+ if filename:
|
|
|
|
|
+ fname = filename.split("/")[-1] if "/" in filename else filename
|
|
|
|
|
+ expected_keys.append((printer_id, fname))
|
|
|
|
|
+ base = fname.replace(".gcode", "").replace(".3mf", "")
|
|
|
|
|
+ expected_keys.append((printer_id, base))
|
|
|
|
|
+ expected_keys.append((printer_id, f"{base}.3mf"))
|
|
|
|
|
+
|
|
|
|
|
+ expected_archive_id = None
|
|
|
|
|
+ for key in expected_keys:
|
|
|
|
|
+ expected_archive_id = _expected_prints.pop(key, None)
|
|
|
|
|
+ _expected_print_registered_at.pop(key, None)
|
|
|
|
|
+ if expected_archive_id:
|
|
|
|
|
+ for other_key in expected_keys:
|
|
|
|
|
+ _expected_prints.pop(other_key, None)
|
|
|
|
|
+ _expected_print_registered_at.pop(other_key, None)
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ if expected_archive_id:
|
|
|
|
|
+ _active_prints[(printer_id, archive_filename)] = expected_archive_id
|
|
|
|
|
+ if subtask_name:
|
|
|
|
|
+ _active_prints[(printer_id, f"{subtask_name}.3mf")] = expected_archive_id
|
|
|
|
|
+
|
|
|
|
|
+ return expected_archive_id
|
|
|
|
|
+
|
|
|
|
|
+ def test_promotion_populates_active_prints(self):
|
|
|
|
|
+ """After promotion, archive is in _active_prints."""
|
|
|
|
|
+ register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+
|
|
|
|
|
+ archive_id = self._simulate_expected_print_promotion(
|
|
|
|
|
+ printer_id=1,
|
|
|
|
|
+ subtask_name="Box",
|
|
|
|
|
+ filename="Box.gcode",
|
|
|
|
|
+ archive_filename="Box.3mf",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert archive_id == 54
|
|
|
|
|
+ assert _active_prints[(1, "Box.3mf")] == 54
|
|
|
|
|
+
|
|
|
|
|
+ def test_promotion_cleans_up_expected_prints(self):
|
|
|
|
|
+ """After promotion, _expected_prints is empty for this print."""
|
|
|
|
|
+ register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+
|
|
|
|
|
+ self._simulate_expected_print_promotion(
|
|
|
|
|
+ printer_id=1,
|
|
|
|
|
+ subtask_name="Box",
|
|
|
|
|
+ filename="Box.gcode",
|
|
|
|
|
+ archive_filename="Box.3mf",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # All variants should be cleaned up
|
|
|
|
|
+ assert (1, "Box.3mf") not in _expected_prints
|
|
|
|
|
+ assert (1, "Box") not in _expected_prints
|
|
|
|
|
+ assert (1, "Box.gcode") not in _expected_prints
|
|
|
|
|
+
|
|
|
|
|
+ def test_ams_mapping_survives_promotion(self):
|
|
|
|
|
+ """_print_ams_mappings is NOT consumed during promotion — it's needed at completion."""
|
|
|
|
|
+ register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+
|
|
|
|
|
+ self._simulate_expected_print_promotion(
|
|
|
|
|
+ printer_id=1,
|
|
|
|
|
+ subtask_name="Box",
|
|
|
|
|
+ filename="Box.gcode",
|
|
|
|
|
+ archive_filename="Box.3mf",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # ams_mapping should still be available for on_print_complete
|
|
|
|
|
+ assert _print_ams_mappings[54] == [1]
|
|
|
|
|
+
|
|
|
|
|
+ def test_completion_lookup_finds_promoted_archive(self):
|
|
|
|
|
+ """Simulate on_print_complete finding the archive in _active_prints."""
|
|
|
|
|
+ register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+
|
|
|
|
|
+ self._simulate_expected_print_promotion(
|
|
|
|
|
+ printer_id=1,
|
|
|
|
|
+ subtask_name="Box",
|
|
|
|
|
+ filename="Box.gcode",
|
|
|
|
|
+ archive_filename="Box.3mf",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # Simulate on_print_complete key building
|
|
|
|
|
+ completion_keys = [
|
|
|
|
|
+ (1, "Box.3mf"),
|
|
|
|
|
+ (1, "Box.gcode.3mf"),
|
|
|
|
|
+ (1, "Box"),
|
|
|
|
|
+ ]
|
|
|
|
|
+ found_id = None
|
|
|
|
|
+ for key in completion_keys:
|
|
|
|
|
+ found_id = _active_prints.pop(key, None)
|
|
|
|
|
+ if found_id:
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ assert found_id == 54
|
|
|
|
|
+ # And ams_mapping is retrievable
|
|
|
|
|
+ assert _print_ams_mappings.pop(54, None) == [1]
|
|
|
|
|
+
|
|
|
|
|
+ def test_no_promotion_for_external_print(self):
|
|
|
|
|
+ """When no expected print exists, nothing is promoted."""
|
|
|
|
|
+ archive_id = self._simulate_expected_print_promotion(
|
|
|
|
|
+ printer_id=1,
|
|
|
|
|
+ subtask_name="Benchy",
|
|
|
|
|
+ filename="Benchy.gcode",
|
|
|
|
|
+ archive_filename="Benchy.3mf",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert archive_id is None
|
|
|
|
|
+ assert len(_active_prints) == 0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestAMSMappingInjection:
|
|
|
|
|
+ """Verify ams_mapping injection into usage tracker session."""
|
|
|
|
|
+
|
|
|
|
|
+ def test_injection_into_session(self):
|
|
|
|
|
+ """ams_mapping from _print_ams_mappings is injectable into a session."""
|
|
|
|
|
+ from datetime import datetime, timezone
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.services.usage_tracker import PrintSession, _active_sessions
|
|
|
|
|
+
|
|
|
|
|
+ _active_sessions.clear()
|
|
|
|
|
+
|
|
|
|
|
+ # Create a session without ams_mapping (simulates MQTT not providing it)
|
|
|
|
|
+ session = PrintSession(
|
|
|
|
|
+ printer_id=1,
|
|
|
|
|
+ print_name="Box",
|
|
|
|
|
+ started_at=datetime.now(timezone.utc),
|
|
|
|
|
+ tray_remain_start={},
|
|
|
|
|
+ tray_now_at_start=-1,
|
|
|
|
|
+ spool_assignments={},
|
|
|
|
|
+ ams_mapping=None,
|
|
|
|
|
+ )
|
|
|
|
|
+ _active_sessions[1] = session
|
|
|
|
|
+
|
|
|
|
|
+ # Register expected print with ams_mapping
|
|
|
|
|
+ register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+
|
|
|
|
|
+ # Simulate the injection logic from on_print_start
|
|
|
|
|
+ _stored_map = _print_ams_mappings.get(54)
|
|
|
|
|
+ assert _stored_map == [1]
|
|
|
|
|
+
|
|
|
|
|
+ ut_session = _active_sessions.get(1)
|
|
|
|
|
+ assert ut_session is not None
|
|
|
|
|
+ assert ut_session.ams_mapping is None # before injection
|
|
|
|
|
+
|
|
|
|
|
+ ut_session.ams_mapping = _stored_map # injection
|
|
|
|
|
+ assert ut_session.ams_mapping == [1]
|
|
|
|
|
+
|
|
|
|
|
+ _active_sessions.clear()
|
|
|
|
|
+
|
|
|
|
|
+ def test_no_injection_when_session_already_has_mapping(self):
|
|
|
|
|
+ """Don't overwrite existing ams_mapping in session."""
|
|
|
|
|
+ from datetime import datetime, timezone
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.services.usage_tracker import PrintSession, _active_sessions
|
|
|
|
|
+
|
|
|
|
|
+ _active_sessions.clear()
|
|
|
|
|
+
|
|
|
|
|
+ session = PrintSession(
|
|
|
|
|
+ printer_id=1,
|
|
|
|
|
+ print_name="Box",
|
|
|
|
|
+ started_at=datetime.now(timezone.utc),
|
|
|
|
|
+ tray_remain_start={},
|
|
|
|
|
+ tray_now_at_start=-1,
|
|
|
|
|
+ spool_assignments={},
|
|
|
|
|
+ ams_mapping=[5, 6], # already has mapping from MQTT
|
|
|
|
|
+ )
|
|
|
|
|
+ _active_sessions[1] = session
|
|
|
|
|
+
|
|
|
|
|
+ register_expected_print(1, "Box.3mf", archive_id=54, ams_mapping=[1])
|
|
|
|
|
+
|
|
|
|
|
+ _stored_map = _print_ams_mappings.get(54)
|
|
|
|
|
+ ut_session = _active_sessions.get(1)
|
|
|
|
|
+
|
|
|
|
|
+ # Guard: don't overwrite if session already has a mapping
|
|
|
|
|
+ if ut_session and not ut_session.ams_mapping:
|
|
|
|
|
+ ut_session.ams_mapping = _stored_map
|
|
|
|
|
+
|
|
|
|
|
+ assert ut_session.ams_mapping == [5, 6] # unchanged
|
|
|
|
|
+
|
|
|
|
|
+ _active_sessions.clear()
|