test_spoolman_clear_location.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. """Unit tests for Spoolman location clearing when spools are removed from AMS.
  2. Tests the clear_location_for_removed_spools method to verify that stale
  3. Spoolman locations are cleared during both auto-sync and manual sync,
  4. preventing the "double-booked" slot bug (#921).
  5. """
  6. from unittest.mock import AsyncMock, patch
  7. import pytest
  8. from backend.app.services.spoolman import SpoolmanClient
  9. BAMBU_UUID_A = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
  10. BAMBU_UUID_B = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
  11. BAMBU_UUID_C = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"
  12. PRINTER_NAME = "My Printer"
  13. LOCATION_PREFIX = f"{PRINTER_NAME} - "
  14. def _make_spool(spool_id: int, location: str, tag: str = "", extra: dict | None = None) -> dict:
  15. """Create a mock Spoolman spool dict."""
  16. return {
  17. "id": spool_id,
  18. "location": location,
  19. "extra": extra or {"tag": tag},
  20. }
  21. @pytest.fixture
  22. def client():
  23. """Create a SpoolmanClient without connecting."""
  24. return SpoolmanClient("http://localhost:7912")
  25. class TestClearLocationForRemovedSpools:
  26. """Test the clear_location_for_removed_spools method."""
  27. @pytest.mark.asyncio
  28. async def test_clears_spool_no_longer_in_ams(self, client):
  29. """A spool whose UUID is not in current_tray_uuids should have its location cleared."""
  30. cached_spools = [
  31. _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A),
  32. ]
  33. with patch.object(client, "update_spool", new_callable=AsyncMock, return_value=True) as mock_update:
  34. cleared = await client.clear_location_for_removed_spools(
  35. PRINTER_NAME, current_tray_uuids=set(), cached_spools=cached_spools
  36. )
  37. assert cleared == 1
  38. mock_update.assert_called_once_with(spool_id=1, clear_location=True)
  39. @pytest.mark.asyncio
  40. async def test_keeps_spool_still_in_ams(self, client):
  41. """A spool whose UUID is in current_tray_uuids should not be cleared."""
  42. cached_spools = [
  43. _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A),
  44. ]
  45. with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
  46. cleared = await client.clear_location_for_removed_spools(
  47. PRINTER_NAME, current_tray_uuids={BAMBU_UUID_A}, cached_spools=cached_spools
  48. )
  49. assert cleared == 0
  50. mock_update.assert_not_called()
  51. @pytest.mark.asyncio
  52. async def test_skips_non_bambu_spools(self, client):
  53. """Spools without a 32-char RFID tag should not be cleared (non-Bambu / third-party)."""
  54. cached_spools = [
  55. _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", "SHORT_TAG"),
  56. _make_spool(2, f"{LOCATION_PREFIX}AMS A Slot 2", ""),
  57. ]
  58. with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
  59. cleared = await client.clear_location_for_removed_spools(
  60. PRINTER_NAME, current_tray_uuids=set(), cached_spools=cached_spools
  61. )
  62. assert cleared == 0
  63. mock_update.assert_not_called()
  64. @pytest.mark.asyncio
  65. async def test_skips_spools_from_other_printers(self, client):
  66. """Spools with locations for a different printer should not be touched."""
  67. cached_spools = [
  68. _make_spool(1, "Other Printer - AMS A Slot 1", BAMBU_UUID_A),
  69. ]
  70. with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
  71. cleared = await client.clear_location_for_removed_spools(
  72. PRINTER_NAME, current_tray_uuids=set(), cached_spools=cached_spools
  73. )
  74. assert cleared == 0
  75. mock_update.assert_not_called()
  76. @pytest.mark.asyncio
  77. async def test_synced_spool_ids_protects_location_matched_spools(self, client):
  78. """Spools in synced_spool_ids should not be cleared even if UUID doesn't match."""
  79. cached_spools = [
  80. _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A),
  81. ]
  82. with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
  83. cleared = await client.clear_location_for_removed_spools(
  84. PRINTER_NAME,
  85. current_tray_uuids=set(),
  86. cached_spools=cached_spools,
  87. synced_spool_ids={1},
  88. )
  89. assert cleared == 0
  90. mock_update.assert_not_called()
  91. @pytest.mark.asyncio
  92. async def test_clears_only_removed_spools_in_mixed_set(self, client):
  93. """With multiple spools at a printer, only clear the one that was removed."""
  94. cached_spools = [
  95. _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A), # Still in AMS
  96. _make_spool(2, f"{LOCATION_PREFIX}AMS A Slot 2", BAMBU_UUID_B), # Removed
  97. _make_spool(3, f"{LOCATION_PREFIX}AMS A Slot 3", BAMBU_UUID_C), # Still in AMS
  98. ]
  99. with patch.object(client, "update_spool", new_callable=AsyncMock, return_value=True) as mock_update:
  100. cleared = await client.clear_location_for_removed_spools(
  101. PRINTER_NAME,
  102. current_tray_uuids={BAMBU_UUID_A, BAMBU_UUID_C},
  103. cached_spools=cached_spools,
  104. )
  105. assert cleared == 1
  106. mock_update.assert_called_once_with(spool_id=2, clear_location=True)
  107. @pytest.mark.asyncio
  108. async def test_uuid_comparison_is_case_insensitive(self, client):
  109. """UUID matching should work regardless of case."""
  110. cached_spools = [
  111. _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A.lower()),
  112. ]
  113. with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
  114. cleared = await client.clear_location_for_removed_spools(
  115. PRINTER_NAME,
  116. current_tray_uuids={BAMBU_UUID_A}, # Uppercase
  117. cached_spools=cached_spools,
  118. )
  119. assert cleared == 0
  120. mock_update.assert_not_called()
  121. @pytest.mark.asyncio
  122. async def test_returns_zero_when_no_spools_at_printer(self, client):
  123. """When no spools have locations for this printer, nothing is cleared."""
  124. with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
  125. cleared = await client.clear_location_for_removed_spools(
  126. PRINTER_NAME, current_tray_uuids=set(), cached_spools=[]
  127. )
  128. assert cleared == 0
  129. mock_update.assert_not_called()
  130. @pytest.mark.asyncio
  131. async def test_double_booking_scenario(self, client):
  132. """Reproduce #921: two spools assigned to the same printer location.
  133. When SpoolA is removed and SpoolB takes its slot, SpoolA's old location
  134. should be cleared because its UUID is no longer in current_tray_uuids.
  135. """
  136. cached_spools = [
  137. _make_spool(1, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_A), # OLD — was removed
  138. _make_spool(2, f"{LOCATION_PREFIX}AMS A Slot 1", BAMBU_UUID_B), # NEW — just inserted
  139. ]
  140. with patch.object(client, "update_spool", new_callable=AsyncMock, return_value=True) as mock_update:
  141. cleared = await client.clear_location_for_removed_spools(
  142. PRINTER_NAME,
  143. current_tray_uuids={BAMBU_UUID_B}, # Only SpoolB is in AMS now
  144. cached_spools=cached_spools,
  145. synced_spool_ids={2}, # SpoolB was just synced
  146. )
  147. assert cleared == 1
  148. mock_update.assert_called_once_with(spool_id=1, clear_location=True)