mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 12:11:31 +02:00
fix(spoolman): AMS syncs write only the slots whose spool they changed and keep a spool assigned meanwhile; the Sync buttons announce slot changes and update the slot preset; a new Spoolman spool is not seeded from leftover built-in assignments
This commit is contained in:
@@ -48,12 +48,12 @@ def _tray(ams_id: int, tray_id: int):
|
||||
return tray
|
||||
|
||||
|
||||
async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
|
||||
async def _run_ams_change(printer_id: int, ams_data: list, *, parsed, sync=None):
|
||||
"""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.
|
||||
occupied or cleared. ``sync`` stands in for ``sync_ams_tray``.
|
||||
"""
|
||||
from backend.app.main import on_ams_change
|
||||
|
||||
@@ -63,14 +63,17 @@ async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
|
||||
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})
|
||||
spoolman.sync_ams_tray = sync or AsyncMock(return_value={"id": 4242})
|
||||
|
||||
status = _status(ams_data)
|
||||
ws = MagicMock()
|
||||
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.ws_manager", ws),
|
||||
# The slot writes are announced by the shared writer.
|
||||
patch("backend.app.services.spoolman_slot_sync.ws_manager", 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",
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""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)]
|
||||
Reference in New Issue
Block a user