| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248 |
- """What the Spoolman AMS syncs write to the slot table, and announce (SM-C3, SSA-2, SSA-3).
- - The automatic pass read the slot table, then awaited Spoolman for every tray
- and wrote every synced slot back, unchanged ones included. A spool assigned
- by hand in between was overwritten with what the pass had read. It now writes
- only slots whose spool it changed, and only while they still hold what it read.
- - The Sync buttons in Settings wrote slots without announcing them, refreshing
- the slot's preset or clearing its grace hold, as the automatic pass does.
- - Both seeded a newly created spool's weight from the built-in inventory's
- slot assignments, which in Spoolman mode are left over from before the switch.
- """
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select, update
- from backend.app.models.spool import Spool
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.services.spoolman import AMSTray
- from backend.tests.integration.test_spoolman_ams_sync_broadcast import (
- _enable_spoolman,
- _run_ams_change,
- _slot_events,
- _tray,
- )
- pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
- async def _slots(db_session, printer_id) -> dict[tuple[int, int], int]:
- rows = (
- await db_session.execute(
- select(SpoolmanSlotAssignment)
- .where(SpoolmanSlotAssignment.printer_id == printer_id)
- .execution_options(populate_existing=True)
- )
- ).scalars()
- return {(row.ams_id, row.tray_id): row.spoolman_spool_id for row in rows}
- async def _printer_with_slot(db_session, printer_factory, spool_id: int = 7) -> int:
- await _enable_spoolman(db_session)
- printer = await printer_factory()
- db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=spool_id))
- await db_session.commit()
- return printer.id
- def _assigning_by_hand_meanwhile(db_session, printer_id, slot, spool_id, returns):
- """sync_ams_tray, during which the user assigns *spool_id* to *slot*."""
- async def sync(tray, *args, **kwargs):
- await db_session.execute(
- update(SpoolmanSlotAssignment)
- .where(
- SpoolmanSlotAssignment.printer_id == printer_id,
- SpoolmanSlotAssignment.ams_id == slot[0],
- SpoolmanSlotAssignment.tray_id == slot[1],
- )
- .values(spoolman_spool_id=spool_id)
- )
- await db_session.commit()
- return returns
- return AsyncMock(side_effect=sync)
- AMS = [{"id": 0, "tray": [{"id": 0}, {"id": 1}]}]
- @pytest.mark.parametrize("synced", [7, 8], ids=["unchanged", "changed"])
- async def test_the_automatic_pass_keeps_a_spool_assigned_meanwhile(
- async_client: AsyncClient, db_session, printer_factory, synced
- ):
- printer_id = await _printer_with_slot(db_session, printer_factory)
- sync = _assigning_by_hand_meanwhile(db_session, printer_id, (0, 0), 9, {"id": synced})
- broadcast, _client = await _run_ams_change(printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=sync)
- assert await _slots(db_session, printer_id) == {(0, 0): 9}
- assert (0, 0) not in _slot_events(broadcast), "nothing the pass wrote changed"
- async def test_the_automatic_pass_announces_no_slot_it_left_as_it_was(
- async_client: AsyncClient, db_session, printer_factory
- ):
- printer_id = await _printer_with_slot(db_session, printer_factory)
- broadcast, _client = await _run_ams_change(
- printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(return_value={"id": 7})
- )
- assert await _slots(db_session, printer_id) == {(0, 0): 7}
- assert (0, 0) not in _slot_events(broadcast)
- async def test_the_automatic_pass_still_writes_and_announces_what_it_changed(
- async_client: AsyncClient, db_session, printer_factory
- ):
- printer_id = await _printer_with_slot(db_session, printer_factory)
- broadcast, _client = await _run_ams_change(
- printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(return_value={"id": 8})
- )
- assert await _slots(db_session, printer_id) == {(0, 0): 8}
- assert _slot_events(broadcast) == {(0, 0)}
- # ── The Sync buttons ─────────────────────────────────────────────────────────
- PATHS = ["/api/v1/spoolman/sync/{printer_id}", "/api/v1/spoolman/sync-all"]
- def _parsed_tray(ams_id, tray_data):
- return AMSTray(
- ams_id=ams_id,
- tray_id=int(tray_data.get("id", 0)),
- tray_type="PLA",
- tray_sub_brands="PLA Matte",
- tray_color="042F56FF",
- remain=-1,
- tag_uid="7757EF0100000100",
- tray_uuid="EB335968299543078925C71D83DA3864",
- tray_info_idx="GFA01",
- tray_weight=1000,
- )
- async def _manual_sync(async_client, printer_id, path, synced: int, *, trays=None, sync=None):
- trays = trays or [{"id": 0, "tray_type": "PLA"}]
- state = SimpleNamespace(state="IDLE", raw_data={"ams": [{"id": 0, "tray": trays}]})
- client = MagicMock()
- client.health_check = AsyncMock(return_value=True)
- client.get_spools = AsyncMock(return_value=[])
- # A tray without a type reads empty.
- client.parse_ams_tray = MagicMock(
- side_effect=lambda ams_id, tray: _parsed_tray(ams_id, tray) if tray.get("tray_type") else None
- )
- client.sync_ams_tray = sync or AsyncMock(return_value={"id": synced})
- preset = AsyncMock()
- with (
- patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
- patch("backend.app.api.routes.spoolman.printer_manager") as pm,
- patch("backend.app.services.spoolman_slot_sync.ws_manager") as ws,
- patch("backend.app.services.slot_preset_writer.upsert_slot_preset_for_spoolman_spool", preset),
- patch("backend.app.services.slot_unlink_grace.forget_slot") as forget,
- ):
- ws.broadcast = AsyncMock()
- pm.get_status = MagicMock(return_value=state)
- pm.get_all_statuses = MagicMock(return_value={printer_id: state})
- response = await async_client.post(path.format(printer_id=printer_id))
- assert response.status_code == 200, response.text
- return client, ws.broadcast, preset, forget
- @pytest.mark.parametrize("path", PATHS)
- async def test_a_sync_button_announces_a_changed_slot(async_client: AsyncClient, db_session, printer_factory, path):
- printer_id = await _printer_with_slot(db_session, printer_factory)
- _client, broadcast, preset, forget = await _manual_sync(async_client, printer_id, path, synced=8)
- assert await _slots(db_session, printer_id) == {(0, 0): 8}
- assert _slot_events(broadcast) == {(0, 0)}
- assert preset.await_args.kwargs["spoolman_spool"] == {"id": 8}
- forget.assert_called_once_with(printer_id, 0, 0)
- @pytest.mark.parametrize("path", PATHS)
- async def test_a_sync_button_does_not_seed_a_spool_from_the_built_in_inventory(
- async_client: AsyncClient, db_session, printer_factory, path
- ):
- """A slot assignment left from before the switch to Spoolman: 880 g of
- 1000 used, nothing to do with the spool now in the slot."""
- printer_id = await _printer_with_slot(db_session, printer_factory)
- stale = Spool(material="PLA", label_weight=1000, weight_used=880)
- db_session.add(stale)
- await db_session.flush()
- db_session.add(SpoolAssignment(spool_id=stale.id, printer_id=printer_id, ams_id=0, tray_id=0))
- await db_session.commit()
- client, *_ = await _manual_sync(async_client, printer_id, path, synced=8)
- assert client.sync_ams_tray.await_args.kwargs.get("inventory_remaining") is None
- @pytest.mark.parametrize("path", PATHS)
- async def test_a_sync_button_keeps_a_slot_that_was_filled_meanwhile(
- async_client: AsyncClient, db_session, printer_factory, path
- ):
- """Slot 0 read empty, so the sync would unlink spool 7; by then it holds 9."""
- printer_id = await _printer_with_slot(db_session, printer_factory)
- sync = _assigning_by_hand_meanwhile(db_session, printer_id, (0, 0), 9, {"id": 5})
- await _manual_sync(
- async_client, printer_id, path, synced=5, trays=[{"id": 0}, {"id": 1, "tray_type": "PLA"}], sync=sync
- )
- assert await _slots(db_session, printer_id) == {(0, 0): 9, (0, 1): 5}
- @pytest.mark.parametrize("path", PATHS)
- async def test_a_sync_button_still_unlinks_a_slot_that_reads_empty(
- async_client: AsyncClient, db_session, printer_factory, path
- ):
- printer_id = await _printer_with_slot(db_session, printer_factory)
- _client, broadcast, *_ = await _manual_sync(async_client, printer_id, path, synced=5, trays=[{"id": 0}])
- assert await _slots(db_session, printer_id) == {}
- assert _slot_events(broadcast) == {(0, 0)}
- async def test_the_automatic_pass_keeps_a_spool_assigned_to_a_slot_it_read_empty(
- async_client: AsyncClient, db_session, printer_factory
- ):
- await _enable_spoolman(db_session)
- printer_id = (await printer_factory()).id
- async def sync(tray, *args, **kwargs):
- db_session.add(SpoolmanSlotAssignment(printer_id=printer_id, ams_id=0, tray_id=0, spoolman_spool_id=9))
- await db_session.commit()
- return {"id": 8}
- await _run_ams_change(printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(side_effect=sync))
- assert await _slots(db_session, printer_id) == {(0, 0): 9}
- async def test_without_the_slot_table_as_read_the_syncs_write_as_before(db_session, printer_factory):
- """Its load failed: nothing to compare against, so a swap and a removal are
- written as before rather than dropped."""
- from backend.app.services.spoolman_slot_sync import write_slot_results
- printer_id = await _printer_with_slot(db_session, printer_factory, spool_id=10)
- db_session.add(SpoolmanSlotAssignment(printer_id=printer_id, ams_id=0, tray_id=1, spoolman_spool_id=11))
- await db_session.commit()
- with patch("backend.app.services.spoolman_slot_sync.ws_manager") as ws:
- ws.broadcast = AsyncMock()
- changed = await write_slot_results(db_session, None, [(printer_id, 0, 0, 99)], [(printer_id, 0, 1)])
- assert await _slots(db_session, printer_id) == {(0, 0): 99}
- assert sorted(changed) == [(printer_id, 0, 0), (printer_id, 0, 1)]
|