Files
bambuddy/backend/tests/unit/services/test_spool_assignment_notifications.py
T
maziggy 08a58b1ef4 Keep both modes' slot assignments across an inventory mode switch (issue #2812)
Turning Spoolman mode on ran an unfiltered delete(SpoolAssignment) across every
printer. Turning it straight back off cleared the other table instead, so the
two directions were symmetric in code and one-way in effect, and the setting
auto-saves on a 500 ms debounce with no save button and no confirmation.
Opening the settings page to see what the option did was enough to destroy the
configuration: the reporter's log shows four toggles in 85 seconds, which is
someone looking and reverting, and the assignments never came back.

The deletion was not careless. Checks that read both assignment tables would
otherwise let a row in the mode you are not using answer for the mode you are,
which is how #1473 was fixed, and emptying the inactive table made that
impossible by construction. The cost was that the guarantee was bought with the
user's data. That decision belongs to the readers -- the mode is a property of
the install, not of the rows -- so spoolman_owns_assignments now answers it and
nothing is deleted on a toggle. Each mode keeps its own assignments and
switching is reversible. Existing installs need no migration: their inactive
table is already empty, because it was being emptied.

Six sites had to be told which mode they meant, and only two of them are the
reads you would guess at, the missing-assignment notification and the queue cost
estimate. The per-slot K-profile lookup consults the built-in table first and,
on a hit with no matching profile, deliberately stops rather than falling
through to Spoolman, so a leftover row would have shadowed the Spoolman binding
for that slot -- the symptom #1556 reported from the other direction.
configure_ams_slot *writes* a K-profile against whichever table answers first,
so the same leftover would have filed a calibration against a spool the printer
is not drawing on and never written the local one, leaving a calibration that
appeared to succeed and then did not apply.

The auto-unlink pass in on_ams_change is the one that would have made this
change worthless. It drops any assignment whose tray no longer matches the
fingerprint it recorded, and it ends in db.delete. Ungated, it would have
removed the preserved rows one slot at a time as the AMS contents changed under
the other mode -- the same loss, arriving slowly enough not to be connected to
the toggle that caused it.

The sixth is the built-in remaining-weight fallback inside the Spoolman AMS
sync, and it is deliberately left inert rather than woken up. It could never
fire while the table it reads was being emptied, it is keyed by slot rather than
by spool, and create_spool writes remaining_weight unconditionally where the
update path does not -- so preserving the rows would have seeded a stale figure
into a brand new Spoolman spool the first time a tray reported an unusable
remain%. The query stays, gated off, so the intent survives for whoever
revisits the cross-mode fallback.

Separately, a print that could not debit a spool said nothing about it, and that
is what turned a mis-click into lost filament. The reporter's print was already
running when they toggled. At completion it resolved its 3MF, read its
per-filament grams, resolved its tray, and then skipped the debit because the
assignment row no longer existed -- logged at INFO, invisible under the default
log level, while the completion notification fired as usual. 65.49 g was never
deducted and they only noticed because a spool's remaining weight looked wrong.
_resolve_spool_id_for_tray has no tag or fingerprint fallback, so there was
nothing else to catch it.

The skip is now a warning naming the grams, and a completed print that failed to
charge a tray it drew from raises the missing-spool-assignment notification. The
print-start check cannot cover this and was right to stay quiet: the assignments
existed when it ran. The two are different statements -- the first says the
weight may not be tracked, the second says it was not -- so a print warned at
start will notify twice, which is the right trade. Collected across the print
rather than fired per slot, and given the caller's session, because this runs
inside on_print_complete's transaction and opening a second one to read the
printer's name would deadlock against it on SQLite. This is independent of the
toggle and catches any other cause of an assignment disappearing mid-print.
2026-08-25 17:17:26 +02:00

210 lines
8.2 KiB
Python

"""Unit tests for spool assignment notification service."""
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.services.spool_assignment_notifications import notify_missing_spool_assignments_on_print_start
class _FakeAssignmentsResult:
def __init__(self, rows):
self._rows = rows
def fetchall(self):
return self._rows
class _FakeSession:
"""Fake DB session that returns legacy vs. Spoolman assignment rows based
on which table the SELECT targets, so tests can exercise either mode."""
def __init__(
self,
printer_name: str,
legacy: list[SimpleNamespace] | None = None,
spoolman: list[SimpleNamespace] | None = None,
):
self._printer = SimpleNamespace(name=printer_name)
self._legacy = legacy or []
self._spoolman = spoolman or []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, model, key):
return self._printer
async def execute(self, statement):
table = statement.get_final_froms()[0].name
if table == "spoolman_slot_assignments":
return _FakeAssignmentsResult(self._spoolman)
return _FakeAssignmentsResult(self._legacy)
@pytest.mark.asyncio
async def test_missing_assignment_broadcasts_websocket_event_and_push_notification():
"""When a mapped tray is unassigned, service emits websocket and notification events."""
logger = logging.getLogger(__name__)
data = {
"ams_mapping": [1],
"raw_data": {},
}
# Assignment exists for A1 (global tray 0), but print uses A2 (global tray 1).
assignments = [SimpleNamespace(ams_id=0, tray_id=0)]
with (
patch(
"backend.app.services.spool_assignment_notifications.async_session",
return_value=_FakeSession("Printer A", assignments),
),
patch("backend.app.services.spool_assignment_notifications.printer_manager.get_status", return_value=None),
patch(
"backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
new_callable=AsyncMock,
) as mock_ws,
patch(
"backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment",
new_callable=AsyncMock,
) as mock_notify,
):
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_awaited_once()
ws_kwargs = mock_ws.await_args.kwargs
assert ws_kwargs["printer_id"] == 1
assert ws_kwargs["printer_name"] == "Printer A"
assert ws_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
mock_notify.assert_awaited_once()
notify_kwargs = mock_notify.await_args.kwargs
assert notify_kwargs["printer_id"] == 1
assert notify_kwargs["printer_name"] == "Printer A"
assert notify_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
def _patches(session, spoolman_mode: bool = False):
"""Common patch set: the fake session + stubbed printer state / emitters.
``spoolman_mode`` decides which assignment table the check reads. Since
#2812 it reads one, not both: nothing empties the inactive table on a mode
toggle any more, so a leftover row there must not vouch for a tray.
"""
return (
patch(
"backend.app.services.spool_assignment_notifications.spoolman_owns_assignments",
new_callable=AsyncMock,
return_value=spoolman_mode,
),
patch(
"backend.app.services.spool_assignment_notifications.async_session",
return_value=session,
),
patch("backend.app.services.spool_assignment_notifications.printer_manager.get_status", return_value=None),
patch(
"backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
new_callable=AsyncMock,
),
patch(
"backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment",
new_callable=AsyncMock,
),
)
@pytest.mark.asyncio
async def test_spoolman_only_assignment_suppresses_notification():
"""#1473 — trays bound only via spoolman_slot_assignments must NOT be
flagged missing (the legacy spool_assignment table is empty in Spoolman
mode, so checking it alone fired a false positive on every print)."""
logger = logging.getLogger(__name__)
data = {"ams_mapping": [0, 1], "raw_data": {}} # print uses A1 + A2
# Both used trays bound via Spoolman; legacy table empty.
session = _FakeSession(
"Printer A",
legacy=[],
spoolman=[SimpleNamespace(ams_id=0, tray_id=0), SimpleNamespace(ams_id=0, tray_id=1)],
)
p_mode, p_session, p_status, p_ws, p_notify = _patches(session, spoolman_mode=True)
with p_mode, p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_not_awaited()
mock_notify.assert_not_awaited()
@pytest.mark.asyncio
async def test_spoolman_partial_coverage_flags_only_uncovered_tray():
"""A Spoolman assignment for A1 only, with a print using A1 + A2, flags
A2 alone."""
logger = logging.getLogger(__name__)
data = {"ams_mapping": [0, 1], "raw_data": {}}
session = _FakeSession(
"Printer A",
legacy=[],
spoolman=[SimpleNamespace(ams_id=0, tray_id=0)], # A1 only
)
p_mode, p_session, p_status, p_ws, p_notify = _patches(session, spoolman_mode=True)
with p_mode, p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_awaited_once()
assert mock_ws.await_args.kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
mock_notify.assert_awaited_once()
@pytest.mark.asyncio
async def test_a_row_in_the_inactive_modes_table_does_not_vouch_for_a_tray():
"""A1 bound in the legacy table, A2 bound in spoolman_slot_assignments.
This used to union both and stay quiet. That was safe only because the mode
toggle emptied whichever table the current mode was not using, so the two
could never both hold rows -- and that emptying is what destroyed people's
assignments for merely looking at the other mode (#2812). Nothing is
emptied now, so in Spoolman mode the legacy row for A1 is a leftover from
before the switch and says nothing about whether A1 is assigned today. A1
is exactly the tray this notification exists to flag.
"""
logger = logging.getLogger(__name__)
data = {"ams_mapping": [0, 1], "raw_data": {}}
session = _FakeSession(
"Printer A",
legacy=[SimpleNamespace(ams_id=0, tray_id=0)], # A1 — leftover, not current
spoolman=[SimpleNamespace(ams_id=0, tray_id=1)], # A2 — the live binding
)
p_mode, p_session, p_status, p_ws, p_notify = _patches(session, spoolman_mode=True)
with p_mode, p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_awaited_once()
assert mock_ws.await_args.kwargs["missing_slots"] == [{"slot": "A1", "profile": "Unknown", "color": "Unknown"}]
mock_notify.assert_awaited_once()
@pytest.mark.asyncio
async def test_built_in_mode_reads_the_built_in_table():
"""The mirror image: in built-in mode a Spoolman row is the leftover."""
logger = logging.getLogger(__name__)
data = {"ams_mapping": [0, 1], "raw_data": {}}
session = _FakeSession(
"Printer A",
legacy=[SimpleNamespace(ams_id=0, tray_id=0), SimpleNamespace(ams_id=0, tray_id=1)],
spoolman=[SimpleNamespace(ams_id=0, tray_id=0)],
)
p_mode, p_session, p_status, p_ws, p_notify = _patches(session, spoolman_mode=False)
with p_mode, p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_not_awaited()
mock_notify.assert_not_awaited()