|
@@ -0,0 +1,223 @@
|
|
|
|
|
+"""Regression for #1807: false-positive "Print Stopped" notification on the
|
|
|
|
|
+expected-archive reprint path.
|
|
|
|
|
+
|
|
|
|
|
+Bambuddy mints a fresh subtask_id per dispatch (``bambu_mqtt.py:3647``). On a
|
|
|
|
|
+reprint, the archive row is reused — so the stored ``archive.subtask_id`` is
|
|
|
|
|
+still the value from the FIRST run. The earlier ``not archive.subtask_id``
|
|
|
|
|
+guard at ``on_print_start`` skipped the rewrite, so the row kept the stale id.
|
|
|
|
|
+
|
|
|
|
|
+Then, if MQTT reconnects mid-print (which it routinely does — network blips,
|
|
|
|
|
+printer reboots, Bambuddy restarts), ``reconcile_stale_active_prints`` (#1542)
|
|
|
|
|
+compares the printer's live subtask_id against the stored one, sees a
|
|
|
|
|
+mismatch, and synthesises a "missed PRINT COMPLETE" → bogus Print Stopped
|
|
|
|
|
+notification while the print keeps running.
|
|
|
|
|
+
|
|
|
|
|
+The fix: update ``archive.subtask_id`` whenever the new effective id differs
|
|
|
|
|
+from the stored one, not only when the stored one is empty.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+
|
|
|
|
|
+from backend.app.core.config import settings as app_settings
|
|
|
|
|
+from backend.app.main import (
|
|
|
|
|
+ _active_prints,
|
|
|
|
|
+ _expected_print_creators,
|
|
|
|
|
+ _expected_print_registered_at,
|
|
|
|
|
+ _expected_prints,
|
|
|
|
|
+ _print_ams_mappings,
|
|
|
|
|
+ _timelapse_baselines,
|
|
|
|
|
+ register_expected_print,
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture(autouse=True)
|
|
|
|
|
+def _clear_dicts():
|
|
|
|
|
+ _expected_prints.clear()
|
|
|
|
|
+ _expected_print_registered_at.clear()
|
|
|
|
|
+ _expected_print_creators.clear()
|
|
|
|
|
+ _print_ams_mappings.clear()
|
|
|
|
|
+ _active_prints.clear()
|
|
|
|
|
+ _timelapse_baselines.clear()
|
|
|
|
|
+ yield
|
|
|
|
|
+ _expected_prints.clear()
|
|
|
|
|
+ _expected_print_registered_at.clear()
|
|
|
|
|
+ _expected_print_creators.clear()
|
|
|
|
|
+ _print_ams_mappings.clear()
|
|
|
|
|
+ _active_prints.clear()
|
|
|
|
|
+ _timelapse_baselines.clear()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _patches():
|
|
|
|
|
+ return (
|
|
|
|
|
+ patch("backend.app.main.async_session"),
|
|
|
|
|
+ patch("backend.app.main.notification_service"),
|
|
|
|
|
+ patch("backend.app.main.smart_plug_manager"),
|
|
|
|
|
+ patch("backend.app.main.ws_manager"),
|
|
|
|
|
+ patch("backend.app.main.printer_manager"),
|
|
|
|
|
+ patch("backend.app.main.mqtt_relay"),
|
|
|
|
|
+ patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
|
|
|
|
|
+ patch("backend.app.main._load_objects_from_archive"),
|
|
|
|
|
+ patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
|
|
|
|
|
+ patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
|
|
|
|
|
+ patch(
|
|
|
|
|
+ "backend.app.main._list_timelapse_videos",
|
|
|
|
|
+ new=AsyncMock(return_value=([], "/timelapse")),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _build_mocks(mock_printer, mock_archive):
|
|
|
|
|
+ def execute_router(stmt, *args, **kwargs):
|
|
|
|
|
+ sql = str(stmt).lower()
|
|
|
|
|
+ if "from printers" in sql or "from printer " in sql:
|
|
|
|
|
+ return MagicMock(
|
|
|
|
|
+ scalar_one_or_none=MagicMock(return_value=mock_printer),
|
|
|
|
|
+ scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
|
|
|
|
|
+ )
|
|
|
|
|
+ if "from print_archives" in sql or "from print_archive" in sql:
|
|
|
|
|
+ return MagicMock(
|
|
|
|
|
+ scalar_one_or_none=MagicMock(return_value=mock_archive),
|
|
|
|
|
+ scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
|
|
|
|
|
+ )
|
|
|
|
|
+ return MagicMock(
|
|
|
|
|
+ scalar_one_or_none=MagicMock(return_value=None),
|
|
|
|
|
+ scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ mock_session = AsyncMock()
|
|
|
|
|
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
|
|
|
+ mock_session.__aexit__ = AsyncMock()
|
|
|
|
|
+ mock_session.execute = AsyncMock(side_effect=execute_router)
|
|
|
|
|
+ mock_session.commit = AsyncMock()
|
|
|
|
|
+ return mock_session
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _make_archive(*, archive_id: int, stored_subtask_id: str | None):
|
|
|
|
|
+ mock_archive = MagicMock()
|
|
|
|
|
+ mock_archive.id = archive_id
|
|
|
|
|
+ mock_archive.filename = "Ikea-drybox_silicabox.3mf"
|
|
|
|
|
+ mock_archive.subtask_id = stored_subtask_id
|
|
|
|
|
+ mock_archive.print_time_seconds = None
|
|
|
|
|
+ mock_archive.created_by_id = None
|
|
|
|
|
+ mock_archive.printer_id = 1
|
|
|
|
|
+ mock_archive.print_name = "Ikea-drybox_silicabox"
|
|
|
|
|
+ mock_archive.status = "archived"
|
|
|
|
|
+ mock_archive.file_path = f"archives/{archive_id}/Ikea-drybox_silicabox.3mf"
|
|
|
|
|
+ mock_archive.energy_start_kwh = None
|
|
|
|
|
+ mock_archive.timelapse_path = None
|
|
|
|
|
+ return mock_archive
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _make_printer():
|
|
|
|
|
+ mock_printer = MagicMock()
|
|
|
|
|
+ mock_printer.id = 1
|
|
|
|
|
+ mock_printer.auto_archive = True
|
|
|
|
|
+ mock_printer.external_camera_enabled = False
|
|
|
|
|
+ mock_printer.external_camera_url = None
|
|
|
|
|
+ mock_printer.name = "TestP1S"
|
|
|
|
|
+ return mock_printer
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _drive(tmp_path, mock_archive, mqtt_subtask_id: str | None):
|
|
|
|
|
+ """Drive ``on_print_start`` with a print-start payload carrying the
|
|
|
|
|
+ given ``subtask_id`` (the printer-echoed id at PRINT START — set by the
|
|
|
|
|
+ queue dispatcher's fresh ``submission_id``)."""
|
|
|
|
|
+ mock_printer = _make_printer()
|
|
|
|
|
+ register_expected_print(1, mock_archive.filename, archive_id=mock_archive.id, ams_mapping=None)
|
|
|
|
|
+ mock_session = _build_mocks(mock_printer, mock_archive)
|
|
|
|
|
+
|
|
|
|
|
+ (
|
|
|
|
|
+ async_session_p,
|
|
|
|
|
+ notif_p,
|
|
|
|
|
+ plug_p,
|
|
|
|
|
+ ws_p,
|
|
|
|
|
+ pm_p,
|
|
|
|
|
+ relay_p,
|
|
|
|
|
+ _energy,
|
|
|
|
|
+ _load_obj,
|
|
|
|
|
+ _store_spoolman,
|
|
|
|
|
+ _send_start,
|
|
|
|
|
+ _list_tl,
|
|
|
|
|
+ ) = _patches()
|
|
|
|
|
+
|
|
|
|
|
+ with (
|
|
|
|
|
+ async_session_p as mock_session_maker,
|
|
|
|
|
+ notif_p as mock_notif,
|
|
|
|
|
+ plug_p as mock_plug,
|
|
|
|
|
+ ws_p as mock_ws,
|
|
|
|
|
+ pm_p as mock_pm,
|
|
|
|
|
+ relay_p as mock_relay,
|
|
|
|
|
+ _energy,
|
|
|
|
|
+ _load_obj,
|
|
|
|
|
+ _store_spoolman,
|
|
|
|
|
+ _send_start,
|
|
|
|
|
+ _list_tl,
|
|
|
|
|
+ patch.object(app_settings, "base_dir", tmp_path),
|
|
|
|
|
+ ):
|
|
|
|
|
+ mock_session_maker.return_value = mock_session
|
|
|
|
|
+ mock_notif.on_print_start = AsyncMock()
|
|
|
|
|
+ mock_plug.on_print_start = AsyncMock()
|
|
|
|
|
+ mock_ws.send_print_start = AsyncMock()
|
|
|
|
|
+ mock_ws.send_archive_updated = AsyncMock()
|
|
|
|
|
+ mock_relay.on_print_start = AsyncMock()
|
|
|
|
|
+ mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
|
|
|
|
|
+ # last_dispatch_subtask_id fallback shouldn't fire — MQTT carried one.
|
|
|
|
|
+ mock_pm.get_client = MagicMock(return_value=MagicMock(last_dispatch_subtask_id=None))
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.main import on_print_start
|
|
|
|
|
+
|
|
|
|
|
+ await on_print_start(
|
|
|
|
|
+ 1,
|
|
|
|
|
+ {
|
|
|
|
|
+ "filename": mock_archive.filename,
|
|
|
|
|
+ "subtask_name": mock_archive.print_name,
|
|
|
|
|
+ "raw_data": {"subtask_id": mqtt_subtask_id} if mqtt_subtask_id is not None else {},
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_reprint_updates_stale_subtask_id(tmp_path):
|
|
|
|
|
+ """The #1807 case: archive stored an OLD subtask_id from the first run.
|
|
|
|
|
+ On reprint dispatch the printer echoes a fresh one — the archive's
|
|
|
|
|
+ stored id must be rewritten so the reconciler doesn't flag the live
|
|
|
|
|
+ print as stale on next MQTT reconnect."""
|
|
|
|
|
+ archive = _make_archive(archive_id=31, stored_subtask_id="1844213296")
|
|
|
|
|
+
|
|
|
|
|
+ await _drive(tmp_path, archive, mqtt_subtask_id="2103771517")
|
|
|
|
|
+
|
|
|
|
|
+ assert archive.subtask_id == "2103771517", (
|
|
|
|
|
+ "expected-archive reprint promotion must update archive.subtask_id to the "
|
|
|
|
|
+ "new dispatch id; leaving the old value lets reconcile_stale_active_prints "
|
|
|
|
|
+ "synthesise a bogus PRINT COMPLETE on the next MQTT reconnect (#1807)"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_first_run_still_sets_subtask_id(tmp_path):
|
|
|
|
|
+ """Regression guard for the previously-correct first-run path: an
|
|
|
|
|
+ archive with no stored subtask_id must still have it written on the
|
|
|
|
|
+ first MQTT-echoed PRINT START."""
|
|
|
|
|
+ archive = _make_archive(archive_id=99, stored_subtask_id=None)
|
|
|
|
|
+
|
|
|
|
|
+ await _drive(tmp_path, archive, mqtt_subtask_id="2103771517")
|
|
|
|
|
+
|
|
|
|
|
+ assert archive.subtask_id == "2103771517"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_stable_push_does_not_rewrite(tmp_path):
|
|
|
|
|
+ """The original `not archive.subtask_id` guard's intent was to avoid
|
|
|
|
|
+ rewriting on every push that carries the same id. The inequality check
|
|
|
|
|
+ preserves that no-op behaviour: same id in, no rewrite."""
|
|
|
|
|
+ archive = _make_archive(archive_id=15, stored_subtask_id="2103771517")
|
|
|
|
|
+ # Replace the bare attribute with a MagicMock so we can detect any write,
|
|
|
|
|
+ # not just observe the post-call value (which would match even on a
|
|
|
|
|
+ # spurious "store the same value back" rewrite).
|
|
|
|
|
+ initial = archive.subtask_id
|
|
|
|
|
+
|
|
|
|
|
+ await _drive(tmp_path, archive, mqtt_subtask_id="2103771517")
|
|
|
|
|
+
|
|
|
|
|
+ assert archive.subtask_id == initial
|