mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 12:46:49 +02:00
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).
342 lines
13 KiB
Python
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()
|