| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186 |
- """Unit tests for Spoolman location clearing when spools are removed from AMS.
- Tests the clear_location_for_removed_spools method to verify that stale
- Spoolman locations are cleared during both auto-sync and manual sync,
- preventing the "double-booked" slot bug (#921).
- """
- from unittest.mock import AsyncMock, patch
- import pytest
- from backend.app.services.spoolman import SpoolmanClient
- BAMBU_UUID_A = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
- BAMBU_UUID_B = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
- BAMBU_UUID_C = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"
- PRINTER_NAME = "My Printer"
- LOCATION_PREFIX = f"{PRINTER_NAME} - "
- def _make_spool(spool_id: int, location: str, tag: str = "", extra: dict | None = None) -> dict:
- """Create a mock Spoolman spool dict."""
- return {
- "id": spool_id,
- "location": location,
- "extra": extra or {"tag": tag},
- }
- @pytest.fixture
- def client():
- """Create a SpoolmanClient without connecting."""
- return SpoolmanClient("http://localhost:7912")
- class TestClearLocationForRemovedSpools:
- """Test the clear_location_for_removed_spools method."""
- @pytest.mark.asyncio
- async def test_clears_spool_no_longer_in_ams(self, client):
- """A spool whose UUID is not in current_tray_uuids should have its location cleared."""
- cached_spools = [
- _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A),
- ]
- with patch.object(client, "update_spool", new_callable=AsyncMock, return_value=True) as mock_update:
- cleared = await client.clear_location_for_removed_spools(
- PRINTER_NAME, current_tray_uuids=set(), cached_spools=cached_spools
- )
- assert cleared == 1
- mock_update.assert_called_once_with(spool_id=1, clear_location=True)
- @pytest.mark.asyncio
- async def test_keeps_spool_still_in_ams(self, client):
- """A spool whose UUID is in current_tray_uuids should not be cleared."""
- cached_spools = [
- _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A),
- ]
- with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
- cleared = await client.clear_location_for_removed_spools(
- PRINTER_NAME, current_tray_uuids={BAMBU_UUID_A}, cached_spools=cached_spools
- )
- assert cleared == 0
- mock_update.assert_not_called()
- @pytest.mark.asyncio
- async def test_skips_non_bambu_spools(self, client):
- """Spools without a 32-char RFID tag should not be cleared (non-Bambu / third-party)."""
- cached_spools = [
- _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", "SHORT_TAG"),
- _make_spool(2, f"{LOCATION_PREFIX}AMS A Slot 2", ""),
- ]
- with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
- cleared = await client.clear_location_for_removed_spools(
- PRINTER_NAME, current_tray_uuids=set(), cached_spools=cached_spools
- )
- assert cleared == 0
- mock_update.assert_not_called()
- @pytest.mark.asyncio
- async def test_skips_spools_from_other_printers(self, client):
- """Spools with locations for a different printer should not be touched."""
- cached_spools = [
- _make_spool(1, "Other Printer - AMS A Slot 1", BAMBU_UUID_A),
- ]
- with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
- cleared = await client.clear_location_for_removed_spools(
- PRINTER_NAME, current_tray_uuids=set(), cached_spools=cached_spools
- )
- assert cleared == 0
- mock_update.assert_not_called()
- @pytest.mark.asyncio
- async def test_synced_spool_ids_protects_location_matched_spools(self, client):
- """Spools in synced_spool_ids should not be cleared even if UUID doesn't match."""
- cached_spools = [
- _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A),
- ]
- with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
- cleared = await client.clear_location_for_removed_spools(
- PRINTER_NAME,
- current_tray_uuids=set(),
- cached_spools=cached_spools,
- synced_spool_ids={1},
- )
- assert cleared == 0
- mock_update.assert_not_called()
- @pytest.mark.asyncio
- async def test_clears_only_removed_spools_in_mixed_set(self, client):
- """With multiple spools at a printer, only clear the one that was removed."""
- cached_spools = [
- _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A), # Still in AMS
- _make_spool(2, f"{LOCATION_PREFIX}AMS A Slot 2", BAMBU_UUID_B), # Removed
- _make_spool(3, f"{LOCATION_PREFIX}AMS A Slot 3", BAMBU_UUID_C), # Still in AMS
- ]
- with patch.object(client, "update_spool", new_callable=AsyncMock, return_value=True) as mock_update:
- cleared = await client.clear_location_for_removed_spools(
- PRINTER_NAME,
- current_tray_uuids={BAMBU_UUID_A, BAMBU_UUID_C},
- cached_spools=cached_spools,
- )
- assert cleared == 1
- mock_update.assert_called_once_with(spool_id=2, clear_location=True)
- @pytest.mark.asyncio
- async def test_uuid_comparison_is_case_insensitive(self, client):
- """UUID matching should work regardless of case."""
- cached_spools = [
- _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A.lower()),
- ]
- with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
- cleared = await client.clear_location_for_removed_spools(
- PRINTER_NAME,
- current_tray_uuids={BAMBU_UUID_A}, # Uppercase
- cached_spools=cached_spools,
- )
- assert cleared == 0
- mock_update.assert_not_called()
- @pytest.mark.asyncio
- async def test_returns_zero_when_no_spools_at_printer(self, client):
- """When no spools have locations for this printer, nothing is cleared."""
- with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
- cleared = await client.clear_location_for_removed_spools(
- PRINTER_NAME, current_tray_uuids=set(), cached_spools=[]
- )
- assert cleared == 0
- mock_update.assert_not_called()
- @pytest.mark.asyncio
- async def test_double_booking_scenario(self, client):
- """Reproduce #921: two spools assigned to the same printer location.
- When SpoolA is removed and SpoolB takes its slot, SpoolA's old location
- should be cleared because its UUID is no longer in current_tray_uuids.
- """
- cached_spools = [
- _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A), # OLD — was removed
- _make_spool(2, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_B), # NEW — just inserted
- ]
- with patch.object(client, "update_spool", new_callable=AsyncMock, return_value=True) as mock_update:
- cleared = await client.clear_location_for_removed_spools(
- PRINTER_NAME,
- current_tray_uuids={BAMBU_UUID_B}, # Only SpoolB is in AMS now
- cached_spools=cached_spools,
- synced_spool_ids={2}, # SpoolB was just synced
- )
- assert cleared == 1
- mock_update.assert_called_once_with(spool_id=1, clear_location=True)
|