test_scheduler_inventory_remain.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. """Tests for the inventory-remain override builder in print_scheduler (#1508).
  2. The MQTT ``remain`` field on an AMS tray is the printer firmware's
  3. RFID-tracked value, which is ``-1`` for non-Bambu spools (and even when
  4. set diverges from Bambuddy's inventory). When the user has bound an
  5. inventory spool to an AMS slot, that inventory record's
  6. ``label_weight - weight_used`` (or Spoolman's ``remaining_weight``) is
  7. the authoritative remaining-weight signal. These tests verify
  8. ``_build_inventory_remain_overrides`` surfaces those values keyed by
  9. ``global_tray_id`` so the "Prefer Lowest Remaining Filament" sort can
  10. consume them.
  11. """
  12. from types import SimpleNamespace
  13. from unittest.mock import AsyncMock, MagicMock, patch
  14. import pytest
  15. from backend.app.services.print_scheduler import PrintScheduler
  16. @pytest.fixture
  17. def scheduler():
  18. return PrintScheduler()
  19. def _make_async_session_returning(rows: list):
  20. """Build a stub AsyncSession whose .execute() returns an object whose
  21. .all() (and .scalars().all()) yield ``rows``."""
  22. result = MagicMock()
  23. result.all.return_value = rows
  24. scalars = MagicMock()
  25. scalars.all.return_value = rows
  26. result.scalars.return_value = scalars
  27. db = MagicMock()
  28. db.execute = AsyncMock(return_value=result)
  29. return db
  30. class TestInternalInventoryOverrides:
  31. @pytest.mark.asyncio
  32. async def test_returns_remaining_grams_for_bound_slots(self, scheduler):
  33. """Two slots bound; both come back keyed by global_tray_id with the
  34. correct ``label_weight - weight_used`` in grams. This is the
  35. reporter scenario in #1508: slot 1 has a 950 g clone, slot 4 has
  36. a 50 g original — the sort can now actually pick the 50 g spool.
  37. The override builder uses ``select(SpoolAssignment).options(
  38. selectinload(SpoolAssignment.spool))`` (matching the rest of the
  39. codebase), so the rows it iterates expose ``.ams_id``, ``.tray_id``
  40. and ``.spool`` directly — the test stubs the same shape.
  41. """
  42. spool_a = SimpleNamespace(label_weight=1000, weight_used=50) # 950 g remaining
  43. spool_b = SimpleNamespace(label_weight=1000, weight_used=950) # 50 g remaining
  44. rows = [
  45. SimpleNamespace(ams_id=0, tray_id=0, spool=spool_a),
  46. SimpleNamespace(ams_id=0, tray_id=3, spool=spool_b),
  47. ]
  48. loaded = [
  49. {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
  50. {"ams_id": 0, "tray_id": 3, "global_tray_id": 3, "is_external": False},
  51. ]
  52. db = _make_async_session_returning(rows)
  53. with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
  54. out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
  55. assert out == {0: 950.0, 3: 50.0}
  56. @pytest.mark.asyncio
  57. async def test_skips_external_slots(self, scheduler):
  58. """VT / external slots are tracked separately from AMS inventory
  59. bindings — the override builder must not assign them an inventory
  60. remaining value even if (somehow) an assignment row exists.
  61. """
  62. loaded = [
  63. {"ams_id": -1, "tray_id": 0, "global_tray_id": 254, "is_external": True},
  64. ]
  65. db = _make_async_session_returning([])
  66. with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
  67. out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
  68. # DB shouldn't even be queried — nothing AMS-side to look up.
  69. db.execute.assert_not_called()
  70. assert out == {}
  71. @pytest.mark.asyncio
  72. async def test_empty_loaded_returns_empty(self, scheduler):
  73. """No loaded filaments → no overrides. The scheduler short-circuits
  74. before this is called in practice, but the function must be
  75. defensive — it's used in any prefer_lowest dispatch path."""
  76. db = _make_async_session_returning([])
  77. with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
  78. out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=[])
  79. assert out == {}
  80. db.execute.assert_not_called()
  81. @pytest.mark.asyncio
  82. async def test_negative_remaining_clamped_to_zero(self, scheduler):
  83. """An over-consumed spool (weight_used > label_weight) shouldn't
  84. produce a negative grams value — clamped to 0 so the sort treats
  85. it as fully empty rather than "more empty than zero."
  86. """
  87. spool = SimpleNamespace(label_weight=1000, weight_used=1100)
  88. rows = [
  89. SimpleNamespace(ams_id=0, tray_id=0, spool=spool),
  90. ]
  91. loaded = [{"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False}]
  92. db = _make_async_session_returning(rows)
  93. with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
  94. out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
  95. assert out == {0: 0.0}
  96. @pytest.mark.asyncio
  97. async def test_slot_without_binding_absent_from_overrides(self, scheduler):
  98. """A slot that has loaded filament but no inventory binding must
  99. not appear in the override map — the sort then falls back to MQTT
  100. ``remain`` for that one slot, preserving pre-#1508 behaviour.
  101. """
  102. rows = [
  103. SimpleNamespace(
  104. ams_id=0,
  105. tray_id=0,
  106. spool=SimpleNamespace(label_weight=1000, weight_used=100),
  107. ),
  108. ]
  109. loaded = [
  110. {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
  111. {"ams_id": 0, "tray_id": 1, "global_tray_id": 1, "is_external": False},
  112. ]
  113. db = _make_async_session_returning(rows)
  114. with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
  115. out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
  116. assert out == {0: 900.0}
  117. assert 1 not in out
  118. class TestSpoolmanModeOverrides:
  119. @pytest.mark.asyncio
  120. async def test_spoolman_remaining_grams_used_when_available(self, scheduler):
  121. """Spoolman mode: each bound slot's spoolman_spool_id is fetched
  122. through ``_spoolman_remaining_grams``; the result is the same
  123. global-tray-id-keyed grams map. Parity rule with internal mode
  124. (feedback_inventory_modes_parity).
  125. """
  126. rows = [
  127. SimpleNamespace(printer_id=1, ams_id=0, tray_id=0, spoolman_spool_id=42),
  128. SimpleNamespace(printer_id=1, ams_id=0, tray_id=2, spoolman_spool_id=99),
  129. ]
  130. loaded = [
  131. {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
  132. {"ams_id": 0, "tray_id": 2, "global_tray_id": 2, "is_external": False},
  133. ]
  134. db = _make_async_session_returning(rows)
  135. async def _fake_grams(spool_id: int):
  136. return {42: 720.0, 99: 80.0}[spool_id]
  137. with (
  138. patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=True)),
  139. patch(
  140. "backend.app.services.filament_deficit._spoolman_remaining_grams",
  141. new=AsyncMock(side_effect=_fake_grams),
  142. ),
  143. ):
  144. out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
  145. assert out == {0: 720.0, 2: 80.0}
  146. @pytest.mark.asyncio
  147. async def test_spoolman_unreachable_skips_silently(self, scheduler):
  148. """If Spoolman is unreachable for one spool, ``_spoolman_remaining_grams``
  149. returns None and that slot is omitted from the override map —
  150. sorting then falls back to MQTT remain for that slot only.
  151. """
  152. rows = [
  153. SimpleNamespace(printer_id=1, ams_id=0, tray_id=0, spoolman_spool_id=42),
  154. SimpleNamespace(printer_id=1, ams_id=0, tray_id=1, spoolman_spool_id=99),
  155. ]
  156. loaded = [
  157. {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
  158. {"ams_id": 0, "tray_id": 1, "global_tray_id": 1, "is_external": False},
  159. ]
  160. db = _make_async_session_returning(rows)
  161. async def _fake_grams(spool_id: int):
  162. return 500.0 if spool_id == 42 else None
  163. with (
  164. patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=True)),
  165. patch(
  166. "backend.app.services.filament_deficit._spoolman_remaining_grams",
  167. new=AsyncMock(side_effect=_fake_grams),
  168. ),
  169. ):
  170. out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
  171. assert out == {0: 500.0}
  172. assert 1 not in out