Files
bambuddy/backend/tests/unit/test_reconcile_stale_active_prints.py
maziggy d0d6a659e9 fix(reconcile): don't synthesize aborted PRINT COMPLETE on the bare-connect edge before push_status arrives (#1679)
on_printer_status_change fires from MQTT _on_connect BEFORE the first
  push_status round-trips, when PrinterState is still on construction
  defaults (state="unknown", subtask_name=""). The connected-edge
  reconcile was treating that degenerate state as evidence and
  synthesising aborted PRINT COMPLETE for every in-flight archive on
  every Bambuddy restart. The reactive PRINT COMPLETE then created a
  duplicate archive (lookup misses on cleared _active_prints), so
  filament got deducted twice.

  Two-layer guard: gate the reconcile spawn on a real state.state, and
  make _is_active_archive_stale return not-stale on unknown/empty input
  as defensive fallback. #1542 behaviour preserved — real stale archives
  still get caught the moment a real push_status arrives.
2026-06-08 08:18:13 +02:00

267 lines
13 KiB
Python

"""Tests for the connected-edge reconciliation that recovers from missed
PRINT COMPLETE events (#1542 follow-up).
Background: the PRINT COMPLETE MQTT callback is purely reactive to a single
state transition (RUNNING → IDLE / FINISH / FAILED). When the printer
finishes during an MQTT disconnect window — typical on the A1 line with
unstable MQTT keepalives — Bambuddy never observes the transition. If a
smart plug then cuts power between completion and the next reconnect, the
firmware auto-replays whatever's still on the SD card and produces a ghost
print on next power-up. Reporter (#1542 second case) saw this hit 4 out of
4 of his A1s.
These tests cover:
* `_is_active_archive_stale` — the pure decision function for whether an
archive in `status="printing"` should be reconciled given the printer's
current state.
* `reconcile_stale_active_prints` — the orchestrator that queries the DB,
runs the decision function, and synthesises `on_print_complete` for
each stale archive.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.main import _is_active_archive_stale
def _state(state: str, *, subtask_id: str = "", subtask_name: str = "", connected: bool = True) -> SimpleNamespace:
"""Minimal PrinterState stub for the pure decision function."""
return SimpleNamespace(
state=state,
subtask_id=subtask_id,
subtask_name=subtask_name,
connected=connected,
raw_data={},
)
def _archive(
subtask_id: str | None = "ABC123", filename: str = "ghost.3mf", print_name: str = "ghost"
) -> SimpleNamespace:
"""Minimal PrintArchive stub — only the fields the decision function reads."""
return SimpleNamespace(
id=42,
subtask_id=subtask_id,
filename=filename,
print_name=print_name,
)
class TestIsActiveArchiveStale:
"""Decision function — covers all three stale triggers + the
intentionally-conservative no-op cases."""
# Trigger 1: printer is in a terminal state.
@pytest.mark.parametrize("terminal_state", ["IDLE", "FINISH", "FAILED", "idle", "finish", "failed"])
def test_terminal_state_marks_stale(self, terminal_state):
archive = _archive(subtask_id="ABC123")
state = _state(terminal_state, subtask_id="ABC123", subtask_name="ghost")
is_stale, reason = _is_active_archive_stale(archive, state)
assert is_stale is True
assert terminal_state.upper() in reason
# Trigger 2: printer is running a different subtask_id.
def test_subtask_id_changed_marks_stale(self):
archive = _archive(subtask_id="OLD_ID")
state = _state("RUNNING", subtask_id="NEW_ID", subtask_name="something")
is_stale, reason = _is_active_archive_stale(archive, state)
assert is_stale is True
assert "subtask_id" in reason
assert "OLD_ID" in reason
assert "NEW_ID" in reason
# Trigger 3: printer is running but doesn't know what it's running.
def test_empty_subtask_name_marks_stale(self):
archive = _archive(subtask_id="ABC123")
state = _state("RUNNING", subtask_id="", subtask_name="")
is_stale, reason = _is_active_archive_stale(archive, state)
assert is_stale is True
assert "empty" in reason.lower() or "subtask_name" in reason
# Healthy case: same subtask_id, running.
def test_matching_running_print_not_stale(self):
archive = _archive(subtask_id="ABC123")
state = _state("RUNNING", subtask_id="ABC123", subtask_name="ghost")
is_stale, _ = _is_active_archive_stale(archive, state)
assert is_stale is False
# PAUSE is not a stale signal — the print is paused, not ended.
def test_paused_print_with_matching_subtask_not_stale(self):
archive = _archive(subtask_id="ABC123")
state = _state("PAUSE", subtask_id="ABC123", subtask_name="ghost")
is_stale, _ = _is_active_archive_stale(archive, state)
assert is_stale is False
# PREPARE / SLICING are not stale either — pre-print phases.
@pytest.mark.parametrize("pre_running_state", ["PREPARE", "SLICING"])
def test_pre_running_states_with_matching_subtask_not_stale(self, pre_running_state):
archive = _archive(subtask_id="ABC123")
state = _state(pre_running_state, subtask_id="ABC123", subtask_name="ghost")
is_stale, _ = _is_active_archive_stale(archive, state)
assert is_stale is False
# Missing subtask_id on the archive side: don't have evidence either
# way, fall through to the empty-subtask_name check.
def test_archive_with_no_subtask_id_falls_to_subtask_name_check(self):
archive = _archive(subtask_id=None)
state = _state("RUNNING", subtask_id="ANYTHING", subtask_name="something")
# Subtask_name is populated → not stale, no false positive.
is_stale, _ = _is_active_archive_stale(archive, state)
assert is_stale is False
# Missing subtask_id on both sides: still triggers the empty-subtask_name
# branch if the printer doesn't know what it's running.
def test_both_subtask_ids_missing_running_with_empty_name_stale(self):
archive = _archive(subtask_id=None)
state = _state("RUNNING", subtask_id="", subtask_name="")
is_stale, _ = _is_active_archive_stale(archive, state)
assert is_stale is True
# IDLE wins over PRINT-STATE checks — the terminal-state branch fires
# first regardless of what the subtask fields look like.
def test_idle_state_overrides_matching_subtask(self):
archive = _archive(subtask_id="ABC123")
state = _state("IDLE", subtask_id="ABC123", subtask_name="ghost")
is_stale, reason = _is_active_archive_stale(archive, state)
assert is_stale is True
assert "IDLE" in reason
# #1679: defensive pre-push guard. Even if reconcile gets called against
# a PrinterState that's still on construction defaults (state="unknown"
# / empty / None, subtask_name=""), the function must NOT report stale —
# otherwise the reactive PRINT COMPLETE later creates a duplicate
# archive and filament gets double-counted. The on_printer_status_change
# caller is the primary fix (gates the reconcile spawn on real state),
# but this guard is belt-and-braces for any future caller.
@pytest.mark.parametrize("degenerate_state", ["unknown", "UNKNOWN", "Unknown", "", None])
def test_pre_push_state_returns_not_stale_even_with_empty_subtask(self, degenerate_state):
archive = _archive(subtask_id="ABC123")
state = _state(degenerate_state, subtask_id="", subtask_name="")
is_stale, _ = _is_active_archive_stale(archive, state)
assert is_stale is False, (
f"state.state={degenerate_state!r} means MQTT hasn't pushed real data yet; "
"treating an in-flight archive as stale here causes the #1679 "
"duplicate-archive + filament-double-count regression"
)
class TestReconcileStaleActivePrints:
"""Orchestrator-level tests — mock the printer manager + DB session so
we can drive the decision flow end-to-end without standing up real
fixtures.
These cover:
* No printer status (disconnected) → no-op, no on_print_complete fired.
* No active archives → no-op.
* Stale archive → synthesised on_print_complete called with status
``"aborted"`` and the `_reconciled: True` marker so downstream code
can distinguish synthetic from real completions.
* Non-stale archive → on_print_complete NOT called (no false positive
on a healthy in-flight print).
* Exception inside on_print_complete must NOT block reconciliation
for subsequent archives or crash the caller.
"""
@pytest.mark.asyncio
async def test_no_status_skips_reconciliation(self):
from backend.app.main import reconcile_stale_active_prints
with patch("backend.app.main.printer_manager") as mock_pm:
mock_pm.get_status.return_value = None
count = await reconcile_stale_active_prints(printer_id=1)
assert count == 0
@pytest.mark.asyncio
async def test_disconnected_status_skips_reconciliation(self):
from backend.app.main import reconcile_stale_active_prints
with patch("backend.app.main.printer_manager") as mock_pm:
mock_pm.get_status.return_value = _state("RUNNING", connected=False)
count = await reconcile_stale_active_prints(printer_id=1)
# Disconnected state would be making decisions against cached state —
# the connected-edge handler in on_printer_status_change is the only
# place that should drive reconciliation.
assert count == 0
@pytest.mark.asyncio
async def test_no_active_archives_returns_zero(self):
from backend.app.main import reconcile_stale_active_prints
with patch("backend.app.main.printer_manager") as mock_pm:
mock_pm.get_status.return_value = _state("IDLE")
with patch("backend.app.main.async_session") as mock_session:
session_ctx = AsyncMock()
session_ctx.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [])))
mock_session.return_value.__aenter__.return_value = session_ctx
count = await reconcile_stale_active_prints(printer_id=1)
assert count == 0
@pytest.mark.asyncio
async def test_stale_archive_synthesises_aborted_completion(self):
from backend.app.main import reconcile_stale_active_prints
stale = _archive(subtask_id="OLD_ID", filename="ghost.3mf", print_name="ghost")
with patch("backend.app.main.printer_manager") as mock_pm:
mock_pm.get_status.return_value = _state("IDLE", subtask_id="", subtask_name="")
with patch("backend.app.main.async_session") as mock_session:
session_ctx = AsyncMock()
session_ctx.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [stale])))
mock_session.return_value.__aenter__.return_value = session_ctx
with patch("backend.app.main.on_print_complete", new=AsyncMock()) as mock_complete:
count = await reconcile_stale_active_prints(printer_id=1)
assert count == 1
mock_complete.assert_awaited_once()
# Verify the synthesised payload shape.
args, kwargs = mock_complete.call_args
assert args[0] == 1
payload = args[1]
assert payload["status"] == "aborted"
assert payload["filename"] == "ghost.3mf"
assert payload["_reconciled"] is True
@pytest.mark.asyncio
async def test_non_stale_archive_does_not_synthesise(self):
from backend.app.main import reconcile_stale_active_prints
healthy = _archive(subtask_id="ABC123")
with patch("backend.app.main.printer_manager") as mock_pm:
mock_pm.get_status.return_value = _state("RUNNING", subtask_id="ABC123", subtask_name="ghost")
with patch("backend.app.main.async_session") as mock_session:
session_ctx = AsyncMock()
session_ctx.execute = AsyncMock(
return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [healthy]))
)
mock_session.return_value.__aenter__.return_value = session_ctx
with patch("backend.app.main.on_print_complete", new=AsyncMock()) as mock_complete:
count = await reconcile_stale_active_prints(printer_id=1)
assert count == 0
mock_complete.assert_not_called()
@pytest.mark.asyncio
async def test_on_print_complete_failure_does_not_block_rest(self):
"""An exception during one archive's synthesis must not abort
reconciliation for the other archives — and must not propagate to
the caller (the connected-edge handler is a hot path)."""
from backend.app.main import reconcile_stale_active_prints
a1 = _archive(subtask_id="A", filename="a.3mf")
a1.id = 1
a2 = _archive(subtask_id="B", filename="b.3mf")
a2.id = 2
with patch("backend.app.main.printer_manager") as mock_pm:
mock_pm.get_status.return_value = _state("IDLE")
with patch("backend.app.main.async_session") as mock_session:
session_ctx = AsyncMock()
session_ctx.execute = AsyncMock(return_value=MagicMock(scalars=lambda: MagicMock(all=lambda: [a1, a2])))
mock_session.return_value.__aenter__.return_value = session_ctx
# First call raises, second call must still happen.
mock_complete = AsyncMock(side_effect=[RuntimeError("boom"), None])
with patch("backend.app.main.on_print_complete", new=mock_complete):
count = await reconcile_stale_active_prints(printer_id=1)
# Only the second archive is recorded as reconciled (first raised).
assert count == 1
assert mock_complete.await_count == 2