| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- """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; a card still drawing the removed spool is the
- same bug seen from the other side."""
- 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()
- 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()
|