Files
bambuddy/backend/tests/integration/test_spoolman_ams_sync_broadcast.py

171 lines
6.5 KiB
Python

"""Spoolman's AMS sync has to tell the browser the slot changed.
Swap a spool and the AMS slot card keeps showing the *previous* spool's preset
name. The card reads ``slot_preset_mappings.preset_name`` ahead of the live
``tray_info_idx``, so a cached row wins over correct data pushed over the
socket — and everything else on the card rides the status push, which is why it
surfaces as one wrong line rather than an obviously stale card.
Built-in inventory raised ``spool_auto_assigned`` for this (the frontend simply
forgot to invalidate ``slotPresets`` on it). This loop raised nothing at all,
even though it rewrites the very same row through
``upsert_slot_preset_for_spoolman_spool`` — so in Spoolman mode there was no
event to hang an invalidation on, and the stale name stood until an unrelated
refetch.
An emptied slot counts: its ``spoolman_slot_assignments`` row is deleted here,
and a card still drawing the removed spool is the same defect.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.settings import Settings
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
def _status(ams_data):
status = MagicMock()
status.raw_data = {"ams": ams_data, "vt_tray": []}
status.gcode_state = "IDLE"
return status
def _tray(ams_id: int, tray_id: int):
"""What ``client.parse_ams_tray`` hands back for an occupied slot."""
tray = MagicMock()
tray.ams_id = ams_id
tray.tray_id = tray_id
tray.tray_uuid = "EB335968299543078925C71D83DA3864"
tray.tag_uid = "7757EF0100000100"
tray.tray_info_idx = "GFA01"
tray.tray_type = "PLA"
tray.tray_sub_brands = "PLA Matte"
tray.tray_color = "042F56FF"
return tray
async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
"""Drive ``on_ams_change`` with Spoolman standing in for the real server.
``parsed`` maps (ams_id, tray_id) to a parsed tray or ``None`` (empty slot),
which is the only thing that decides whether the sync treats the slot as
occupied or cleared.
"""
from backend.app.main import on_ams_change
spoolman = MagicMock()
spoolman.health_check = AsyncMock(return_value=True)
spoolman.get_spools = AsyncMock(return_value=[])
spoolman.parse_ams_tray = MagicMock(
side_effect=lambda ams_id, tray_data: parsed.get((ams_id, int(tray_data.get("id", 0))))
)
spoolman.sync_ams_tray = AsyncMock(return_value={"id": 4242})
status = _status(ams_data)
with (
patch("backend.app.main.printer_manager") as pm_main,
patch("backend.app.services.printer_manager.printer_manager") as pm_inv,
patch("backend.app.main.mqtt_relay") as relay,
patch("backend.app.main.ws_manager") as ws,
patch("backend.app.main.get_spoolman_client", AsyncMock(return_value=spoolman)),
patch(
"backend.app.services.slot_preset_writer.upsert_slot_preset_for_spoolman_spool",
AsyncMock(),
),
):
pm_main.get_printer.return_value = MagicMock(name="P", serial_number="SER")
pm_main.get_status.return_value = status
pm_main.get_client.return_value = MagicMock()
pm_main.get_model.return_value = "X1C"
pm_inv.get_status.return_value = status
pm_inv.get_client.return_value = MagicMock()
relay.on_ams_change = AsyncMock()
ws.send_printer_status = AsyncMock()
ws.broadcast = AsyncMock()
await on_ams_change(printer_id, ams_data)
return ws.broadcast, spoolman
def _slot_events(broadcast) -> set[tuple[int, int]]:
"""(ams_id, tray_id) of every assignment-changed event that went out."""
return {
(call.args[0]["ams_id"], call.args[0]["tray_id"])
for call in broadcast.call_args_list
if call.args and call.args[0].get("type") == "spool_assignment_changed"
}
async def _enable_spoolman(db: AsyncSession) -> None:
db.add(Settings(key="spoolman_enabled", value="true"))
db.add(Settings(key="spoolman_url", value="http://spoolman.invalid:7912"))
await db.commit()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_synced_slot_is_broadcast(async_client: AsyncClient, printer_factory, db_session: AsyncSession):
printer = await printer_factory(name="H2C")
await _enable_spoolman(db_session)
broadcast, spoolman = await _run_ams_change(
printer.id,
[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "042F56FF", "state": 11}]}],
parsed={(0, 0): _tray(0, 0)},
)
spoolman.sync_ams_tray.assert_awaited()
assert (0, 0) in _slot_events(broadcast)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_emptied_slot_is_broadcast(async_client: AsyncClient, printer_factory, db_session: AsyncSession):
"""The row is deleted here -- once the slot has stayed empty for the grace
period (#3186) -- and a card still drawing the removed spool is the same
bug seen from the other side."""
from backend.app.services import slot_unlink_grace
printer = await printer_factory(name="H2C")
await _enable_spoolman(db_session)
db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=1, spoolman_spool_id=7))
await db_session.commit()
clock = [1000.0]
with patch.object(slot_unlink_grace, "_now", lambda: clock[0]):
await _run_ams_change(printer.id, [{"id": 0, "tray": [{"id": 1}]}], parsed={(0, 1): None})
clock[0] += slot_unlink_grace.GRACE_SECONDS
broadcast, _ = await _run_ams_change(
printer.id,
[{"id": 0, "tray": [{"id": 1}]}],
parsed={(0, 1): None},
)
assert (0, 1) in _slot_events(broadcast)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_nothing_is_broadcast_when_no_slot_changed(
async_client: AsyncClient, printer_factory, db_session: AsyncSession
):
"""A push that changes no slot must stay quiet — this runs on every AMS
message, and an event per push would invalidate the browser's caches
continuously."""
printer = await printer_factory(name="H2C")
await _enable_spoolman(db_session)
broadcast, spoolman = await _run_ams_change(
printer.id,
[{"id": 0, "tray": []}],
parsed={},
)
# Guards the assertion below against passing because the sync bailed out
# early on a mis-set fixture rather than because it found nothing to say.
spoolman.get_spools.assert_awaited()
assert _slot_events(broadcast) == set()