mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
171 lines
6.5 KiB
Python
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()
|