Files
bambuddy/backend/tests/unit/test_internal_printer_jobs.py
T
maziggy 9c93884397 Stop auto K-profile calibration leaving an archive behind
With flow dynamics calibration on, the printer lays down a
pressure-advance line before the print itself and announces it over MQTT
through the same print-start event a real print uses. Bambuddy archived
it: a row named auto_pa_line_calib_mode marked as having no 3MF, sitting
in among the user's actual prints, with a Print Started and a Print
Completed notification for each one.

The printer's other internal jobs were already skipped, but only by the
/usr/ path they carry -- bed levelling reports as
/usr/etc/print/auto_cali_for_user.gcode. The pressure-advance line
carries no path at all. It arrives as a bare subtask name, so a rule
that only ever looked at the filename could not see it. Falling past the
guard it reached the no-3MF fallback, where print_name is subtask_name
or filename, and named the row after the calibration.

Internal jobs are now recognised by name as well as by path, from either
field, in one place both callbacks share. The match is exact after
normalising away the directory, one print-file suffix and case, rather
than a prefix or substring rule: "auto" and "calib" are ordinary words in
a user's own filenames, and a rule loose enough to catch some unnamed
future calibration would quietly swallow somebody's print.

The completion is suppressed too, and that half matters more than the
noise. With no archive to close, the completion falls into the
no-archive notification path -- which attributes an unmatched completion
to any queue item the printer finished in the last five minutes and
emails its owner. This calibration runs alongside a real print, so
silencing only the start would have told that print's owner their job
was done, early, and again for real later. The guard sits inside the
no-archive branch, so the plate-clear gate, the queue reconciliation and
the SD-card cleanup all still run; only the notification is skipped.

Skipping the run early also drops the FTP sweep that preceded the
fallback -- six candidate names across five directories with retries,
around a hundred connections looking for a file that cannot exist, aimed
at a printer that is in the middle of calibrating.

auto_cali_for_user no longer sends a Print Started notification either.
It is the same event about the same kind of job, and the archive was
never the only thing wrong with treating it as a user's print.
2026-08-16 14:01:33 +02:00

213 lines
9.5 KiB
Python

"""The printer's own calibration runs leave no archive and send no notification.
Auto pressure-advance calibration -- the K-profile line the printer lays down
before a print when flow dynamics calibration is on -- reports over MQTT through
the same print-start event a real print uses, as the subtask name
``auto_pa_line_calib_mode`` with no ``/usr/`` path attached. The only guard
Bambuddy had tested ``filename.startswith("/usr/")``, so the calibration sailed
past it, found no 3MF anywhere on the printer (there is none to find), and left
a no-3MF archive named after itself in the user's history.
The same name is already known to the completion guard: #2829's capture of
queue item 649 has ``auto_pa_line_calib_mode`` arriving as the subtask name of a
completion that had to be refused against a running job.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.utils.print_jobs import is_internal_printer_job
class TestTheCalibrationIsRecognised:
def test_the_pressure_advance_line_by_subtask_name(self):
"""How it actually arrives: a bare subtask name, no filename at all."""
assert is_internal_printer_job("", "auto_pa_line_calib_mode")
def test_the_pressure_advance_line_by_filename(self):
"""Both fields are tested, because which one carries it is not fixed."""
assert is_internal_printer_job("auto_pa_line_calib_mode", None)
def test_the_levelling_run_by_its_system_path(self):
assert is_internal_printer_job("/usr/etc/print/auto_cali_for_user.gcode", "auto_cali_for_user")
def test_the_levelling_run_by_name_alone(self):
"""The /usr/ path is not guaranteed, so the name is listed too."""
assert is_internal_printer_job(None, "auto_cali_for_user")
@pytest.mark.parametrize(
"reported",
[
"auto_pa_line_calib_mode",
"auto_pa_line_calib_mode.gcode",
"auto_pa_line_calib_mode.3mf",
"auto_pa_line_calib_mode.gcode.3mf",
"AUTO_PA_LINE_CALIB_MODE",
"/data/auto_pa_line_calib_mode.gcode.3mf",
],
)
def test_however_the_name_is_dressed_up(self, reported):
"""Path, suffix and case all vary between the fields and firmwares."""
assert is_internal_printer_job(reported, None)
def test_anything_under_usr_counts(self):
"""Nothing a user can print lives on the read-only system partition."""
assert is_internal_printer_job("/usr/bin/firmware_test.gcode", "test")
class TestItLeavesRealPrintsAlone:
"""The failure that matters: swallowing somebody's actual print."""
def test_an_ordinary_print(self):
assert not is_internal_printer_job("Benchy.gcode.3mf", "Benchy")
def test_nothing_reported_at_all(self):
assert not is_internal_printer_job(None, None)
assert not is_internal_printer_job("", "")
@pytest.mark.parametrize(
"reported",
[
"auto_pa_line_calib_mode_v2.3mf",
"my_auto_pa_line_calib_mode.3mf",
"auto_cali_for_user_test.gcode.3mf",
],
)
def test_a_users_file_that_merely_contains_the_name(self, reported):
"""Exact match after normalising, so no prefix or substring rule can
eat a file somebody deliberately named after the calibration."""
assert not is_internal_printer_job(reported, None)
def test_a_calibration_cube(self):
"""The obvious false positive for any rule built on the word 'calib'."""
assert not is_internal_printer_job("Calibration_Cube.gcode.3mf", "Calibration Cube")
def _mocked_print_start():
"""Patch set for driving on_print_start without a printer or database."""
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"),
)
class TestPrintStartSkipsTheCalibration:
@pytest.mark.asyncio
async def test_no_archive_and_no_notification(self, capture_logs):
sess, notif, plug, ws, pm, relay = _mocked_print_start()
with sess as mock_session_maker, notif as mock_notif, plug as mock_plug, ws as mock_ws, pm as mock_pm, relay:
mock_notif.on_print_start = AsyncMock()
mock_plug.on_print_start = AsyncMock()
mock_ws.send_print_start = AsyncMock()
mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
mock_printer = MagicMock()
mock_printer.auto_archive = True
mock_printer.id = 1
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock()
mock_session.execute = AsyncMock(
return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
)
mock_session_maker.return_value = mock_session
with patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock) as mock_notify:
from backend.app.main import on_print_start
# No filename: exactly what the printer reports for this run,
# and the reason the old /usr/ prefix test never fired.
await on_print_start(1, {"filename": "", "subtask_name": "auto_pa_line_calib_mode"})
mock_notify.assert_not_called()
skipped = [r for r in capture_logs.records if "internal printer job" in str(r.message)]
assert skipped, "Should log that the calibration run was skipped"
class TestPrintCompleteStaysQuiet:
@pytest.mark.asyncio
async def test_no_orphan_notification_when_the_calibration_finishes(self):
"""With no archive to close, the completion would otherwise fall into
the no-archive notification path -- which attributes an unmatched
completion to any queue item this printer finished in the last five
minutes. For a calibration running alongside a real print that means
telling its owner their print is done, early and twice.
"""
with (
patch("backend.app.main.async_session") as mock_session_maker,
patch("backend.app.main.ws_manager") as mock_ws,
patch("backend.app.main.printer_manager") as mock_pm,
patch("backend.app.main.mqtt_relay") as mock_relay,
patch("backend.app.main.spawn_background_task") as mock_spawn,
patch("backend.app.main.clear_3mf_cache"),
):
mock_ws.send_print_complete = AsyncMock()
mock_relay.on_print_complete = AsyncMock()
mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
mock_pm.get_current_print_user = MagicMock(return_value=None)
mock_pm.clear_current_print_user = MagicMock()
mock_pm.set_awaiting_plate_clear = MagicMock()
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock()
mock_session.execute = AsyncMock(
return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None), scalars=MagicMock())
)
mock_session_maker.return_value = mock_session
from backend.app.main import on_print_complete
await on_print_complete(
1,
{"filename": "", "subtask_name": "auto_pa_line_calib_mode", "status": "completed"},
)
spawned = [c for c in mock_spawn.call_args_list if "notify-no-archive" in str(c)]
assert not spawned, "No completion notification should be spawned for a calibration run"
@pytest.mark.asyncio
async def test_a_real_orphan_print_still_notifies(self):
"""The no-archive path exists for prints started outside Bambuddy. The
guard must not take those down with it.
"""
with (
patch("backend.app.main.async_session") as mock_session_maker,
patch("backend.app.main.ws_manager") as mock_ws,
patch("backend.app.main.printer_manager") as mock_pm,
patch("backend.app.main.mqtt_relay") as mock_relay,
patch("backend.app.main.spawn_background_task") as mock_spawn,
patch("backend.app.main.clear_3mf_cache"),
):
mock_ws.send_print_complete = AsyncMock()
mock_relay.on_print_complete = AsyncMock()
mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
mock_pm.get_current_print_user = MagicMock(return_value=None)
mock_pm.clear_current_print_user = MagicMock()
mock_pm.set_awaiting_plate_clear = MagicMock()
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock()
mock_session.execute = AsyncMock(
return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None), scalars=MagicMock())
)
mock_session_maker.return_value = mock_session
from backend.app.main import on_print_complete
await on_print_complete(
1,
{"filename": "", "subtask_name": "Benchy", "status": "completed"},
)
spawned = [c for c in mock_spawn.call_args_list if "notify-no-archive" in str(c)]
assert spawned, "An unmatched real print must still notify"