test_inventory_remain_endpoint.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. """Tests for GET /printers/{id}/inventory-remain (#1766).
  2. The endpoint exposes the same `_build_inventory_remain_overrides` map the
  3. dispatcher uses so PrintModal's client-side "Prefer Lowest Remaining Filament"
  4. sort agrees with what gets dispatched — closes the gap where Spoolman-mode
  5. users couldn't see inventory grams from the frontend.
  6. It also carries `slot_materials`: every inventory binding with the backend's
  7. material identity and extruder side, which is what lets the modal's pre-flight
  8. filament check pool spools under AMS Filament Backup the way the dispatcher
  9. does instead of resolving spools itself.
  10. """
  11. from types import SimpleNamespace
  12. from unittest.mock import AsyncMock, MagicMock, patch
  13. import pytest
  14. from backend.app.api.routes.printers import get_inventory_remain
  15. from backend.app.services.filament_deficit import SlotMaterial
  16. @pytest.fixture
  17. def db():
  18. return MagicMock()
  19. async def _call_endpoint(db, printer_id=1):
  20. return await get_inventory_remain(printer_id=printer_id, _=None, db=db)
  21. class TestGetInventoryRemain:
  22. @pytest.mark.asyncio
  23. async def test_returns_empty_when_printer_has_no_status(self, db):
  24. # Printer disconnected / unknown — endpoint must not error, return {}.
  25. with patch(
  26. "backend.app.services.printer_manager.printer_manager.get_status",
  27. return_value=None,
  28. ):
  29. result = await _call_endpoint(db)
  30. assert result == {"inventory_remain_g": {}, "slot_materials": []}
  31. @pytest.mark.asyncio
  32. async def test_serialises_globaltrayid_keys_as_strings(self, db):
  33. # JSON requires string keys; client converts back to Number on receive.
  34. # Asserts the key-shape contract the frontend depends on.
  35. state = SimpleNamespace(raw_data={})
  36. with (
  37. patch(
  38. "backend.app.services.printer_manager.printer_manager.get_status",
  39. return_value=state,
  40. ),
  41. patch(
  42. "backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
  43. return_value=[
  44. {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
  45. {"ams_id": 0, "tray_id": 3, "global_tray_id": 3, "is_external": False},
  46. ],
  47. ),
  48. patch(
  49. "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
  50. new=AsyncMock(return_value={0: 950.0, 3: 50.0}),
  51. ),
  52. patch(
  53. "backend.app.services.filament_deficit.build_slot_materials",
  54. new=AsyncMock(return_value=[]),
  55. ),
  56. ):
  57. result = await _call_endpoint(db)
  58. assert result["inventory_remain_g"] == {"0": 950.0, "3": 50.0}
  59. @pytest.mark.asyncio
  60. async def test_returns_empty_dict_when_no_bound_slots(self, db):
  61. # Loaded filaments exist but none are bound to an inventory spool.
  62. # Backend returns {}; route serialises it unchanged.
  63. state = SimpleNamespace(raw_data={})
  64. with (
  65. patch(
  66. "backend.app.services.printer_manager.printer_manager.get_status",
  67. return_value=state,
  68. ),
  69. patch(
  70. "backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
  71. return_value=[
  72. {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
  73. ],
  74. ),
  75. patch(
  76. "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
  77. new=AsyncMock(return_value={}),
  78. ),
  79. patch(
  80. "backend.app.services.filament_deficit.build_slot_materials",
  81. new=AsyncMock(return_value=[]),
  82. ),
  83. ):
  84. result = await _call_endpoint(db)
  85. assert result["inventory_remain_g"] == {}
  86. @pytest.mark.asyncio
  87. async def test_slot_materials_carry_identity_and_extruder(self, db):
  88. # The modal groups on (material_key, extruder) to decide what AMS
  89. # Filament Backup can pool, so both fields have to survive the wire.
  90. # Unlike inventory_remain_g this covers every binding, not just the
  91. # slots currently loaded — the dispatcher pools all of them.
  92. state = SimpleNamespace(raw_data={})
  93. with (
  94. patch(
  95. "backend.app.services.printer_manager.printer_manager.get_status",
  96. return_value=state,
  97. ),
  98. patch(
  99. "backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
  100. return_value=[],
  101. ),
  102. patch(
  103. "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
  104. new=AsyncMock(return_value={}),
  105. ),
  106. patch(
  107. "backend.app.services.filament_deficit.build_slot_materials",
  108. new=AsyncMock(
  109. return_value=[
  110. SlotMaterial(
  111. ams_id=0,
  112. tray_id=2,
  113. global_tray_id=2,
  114. material_key="preset:PFUS6488|color:616777",
  115. remaining_grams=1000.0,
  116. extruder=0,
  117. ),
  118. ]
  119. ),
  120. ),
  121. ):
  122. result = await _call_endpoint(db)
  123. assert result["slot_materials"] == [
  124. {
  125. "ams_id": 0,
  126. "tray_id": 2,
  127. "global_tray_id": 2,
  128. "material_key": "preset:PFUS6488|color:616777",
  129. "remaining_g": 1000.0,
  130. "extruder": 0,
  131. }
  132. ]