Files
bambuddy/backend/tests/unit/test_print_start_expected_promotion.py
T
maziggy 42b1653f52 Fix spool weight tracking when auto-archive disabled for queue/reprint prints (#839)
When auto_archive was disabled but the print was dispatched by BamBuddy
  (queue/reprint), on_print_start discarded the expected print entry and
  returned early. The archive was never promoted to _active_prints, so at
  completion archive_id and ams_mapping were both None — all tracking paths
  failed silently. Now detects expected prints before the auto-archive
  early-return and falls through to the normal promotion path. Also injects
  the stored ams_mapping into the usage tracker session for printers where
  MQTT request topic subscription fails (P1S, A1).
2026-04-08 08:17:42 +02:00

342 lines
13 KiB
Python

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