test_spool_drying_2863.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. """Each spool remembers when it was last dried (#2863).
  2. An AMS drying cycle that ran at least half its length stamps every spool
  3. assigned to that AMS, in whichever inventory holds the slot assignments. The
  4. date can also be set by hand, which clears the temperature and hours that
  5. described the AMS cycle.
  6. """
  7. import json
  8. from datetime import datetime
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from httpx import AsyncClient
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
  14. from backend.app.models.settings import Settings
  15. from backend.app.models.spool import Spool
  16. from backend.app.models.spool_assignment import SpoolAssignment
  17. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  18. from backend.app.services.bambu_mqtt import DryingCycleEnd
  19. from backend.app.services.spool_drying import record_drying_cycle
  20. FULL_CYCLE = DryingCycleEnd(
  21. ams_id=0, remaining_minutes=1, peak_minutes=480, start_seen=True, target_temp=55, target_hours=8
  22. )
  23. SHORT_CYCLE = DryingCycleEnd(
  24. ams_id=0, remaining_minutes=470, peak_minutes=480, start_seen=True, target_temp=55, target_hours=8
  25. )
  26. async def _assign(db: AsyncSession, printer_id: int, ams_id: int, tray_id: int) -> Spool:
  27. spool = Spool(material="PLA", rgba="FF0000FF")
  28. db.add(spool)
  29. await db.flush()
  30. db.add(SpoolAssignment(spool_id=spool.id, printer_id=printer_id, ams_id=ams_id, tray_id=tray_id))
  31. await db.commit()
  32. return spool
  33. @pytest.fixture
  34. def no_broadcast():
  35. with patch("backend.app.core.websocket.ws_manager.broadcast", AsyncMock()) as broadcast:
  36. yield broadcast
  37. class TestBuiltInInventory:
  38. @pytest.mark.asyncio
  39. @pytest.mark.integration
  40. async def test_full_cycle_stamps_only_the_spools_in_that_ams(self, printer_factory, db_session, no_broadcast):
  41. printer = await printer_factory(name="H2D")
  42. in_ams = [await _assign(db_session, printer.id, 0, tray) for tray in (0, 1)]
  43. other_ams = await _assign(db_session, printer.id, 1, 0)
  44. assert await record_drying_cycle(db_session, printer.id, FULL_CYCLE) == 2
  45. for spool in in_ams:
  46. await db_session.refresh(spool)
  47. assert spool.last_dried_at is not None
  48. assert spool.last_dried_temp == 55
  49. assert spool.last_dried_hours == 8.0
  50. await db_session.refresh(other_ams)
  51. assert other_ams.last_dried_at is None
  52. no_broadcast.assert_awaited_once_with({"type": "inventory_changed"})
  53. @pytest.mark.asyncio
  54. @pytest.mark.integration
  55. async def test_cycle_stopped_early_keeps_the_earlier_drying(self, printer_factory, db_session, no_broadcast):
  56. printer = await printer_factory(name="H2D")
  57. spool = await _assign(db_session, printer.id, 0, 0)
  58. earlier = datetime(2026, 9, 1, 12, 0)
  59. spool.last_dried_at = earlier
  60. spool.last_dried_temp = 65
  61. spool.last_dried_hours = 12.0
  62. await db_session.commit()
  63. assert await record_drying_cycle(db_session, printer.id, SHORT_CYCLE) == 0
  64. await db_session.refresh(spool)
  65. assert spool.last_dried_at == earlier
  66. assert spool.last_dried_temp == 65
  67. no_broadcast.assert_not_awaited()
  68. @pytest.mark.asyncio
  69. @pytest.mark.integration
  70. async def test_spoolman_mode_leaves_built_in_rows_alone(self, printer_factory, db_session, no_broadcast):
  71. """The built-in assignments are preserved while Spoolman is active
  72. (#2812); they do not describe what is in the AMS."""
  73. printer = await printer_factory(name="H2D")
  74. spool = await _assign(db_session, printer.id, 0, 0)
  75. db_session.add(Settings(key="spoolman_enabled", value="true"))
  76. await db_session.commit()
  77. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=None)):
  78. await record_drying_cycle(db_session, printer.id, FULL_CYCLE)
  79. await db_session.refresh(spool)
  80. assert spool.last_dried_at is None
  81. @pytest.mark.asyncio
  82. @pytest.mark.integration
  83. async def test_hand_set_date_clears_temperature_and_hours(self, async_client: AsyncClient, db_session):
  84. spool = Spool(
  85. material="PLA",
  86. last_dried_at=datetime(2026, 9, 1, 12, 0),
  87. last_dried_temp=55,
  88. last_dried_hours=8.0,
  89. )
  90. db_session.add(spool)
  91. await db_session.commit()
  92. response = await async_client.patch(
  93. f"/api/v1/inventory/spools/{spool.id}", json={"last_dried_at": "2026-10-06T14:30:00+02:00"}
  94. )
  95. assert response.status_code == 200
  96. body = response.json()
  97. # Stored as UTC, the way every naive DateTime column holds it.
  98. assert body["last_dried_at"].startswith("2026-10-06T12:30:00")
  99. assert body["last_dried_temp"] is None
  100. assert body["last_dried_hours"] is None
  101. @pytest.mark.asyncio
  102. @pytest.mark.integration
  103. async def test_null_clears_the_date(self, async_client: AsyncClient, db_session):
  104. spool = Spool(material="PLA", last_dried_at=datetime(2026, 9, 1, 12, 0), last_dried_temp=55)
  105. db_session.add(spool)
  106. await db_session.commit()
  107. response = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"last_dried_at": None})
  108. assert response.status_code == 200
  109. assert response.json()["last_dried_at"] is None
  110. assert response.json()["last_dried_temp"] is None
  111. @pytest.mark.asyncio
  112. @pytest.mark.integration
  113. async def test_unrelated_edit_keeps_the_record(self, async_client: AsyncClient, db_session):
  114. spool = Spool(material="PLA", last_dried_at=datetime(2026, 9, 1, 12, 0), last_dried_temp=55)
  115. db_session.add(spool)
  116. await db_session.commit()
  117. response = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"note": "shelf 2"})
  118. assert response.status_code == 200
  119. assert response.json()["last_dried_at"].startswith("2026-09-01T12:00:00")
  120. assert response.json()["last_dried_temp"] == 55
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_bulk_set_date_clears_temperature_and_hours(self, async_client: AsyncClient, db_session):
  124. spools = [Spool(material="PLA", last_dried_temp=55, last_dried_hours=8.0) for _ in range(2)]
  125. db_session.add_all(spools)
  126. await db_session.commit()
  127. response = await async_client.post(
  128. "/api/v1/inventory/spools/bulk-update",
  129. json={"ids": [s.id for s in spools], "update": {"last_dried_at": "2026-10-06T12:30:00Z"}},
  130. )
  131. assert response.status_code == 200
  132. for spool in spools:
  133. await db_session.refresh(spool)
  134. assert spool.last_dried_at == datetime(2026, 10, 6, 12, 30)
  135. assert spool.last_dried_temp is None
  136. assert spool.last_dried_hours is None
  137. class TestSpoolmanInventory:
  138. @pytest.mark.asyncio
  139. @pytest.mark.integration
  140. async def test_full_cycle_writes_the_extra_fields(self, printer_factory, db_session, no_broadcast):
  141. printer = await printer_factory(name="H2D")
  142. db_session.add(Settings(key="spoolman_enabled", value="true"))
  143. for tray, spool_id in ((0, 11), (1, 12)):
  144. db_session.add(
  145. SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=tray, spoolman_spool_id=spool_id)
  146. )
  147. db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=1, tray_id=0, spoolman_spool_id=13))
  148. await db_session.commit()
  149. client = MagicMock()
  150. client.merge_spool_extra = AsyncMock(return_value={})
  151. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
  152. assert await record_drying_cycle(db_session, printer.id, FULL_CYCLE) == 2
  153. assert [c.args[0] for c in client.merge_spool_extra.call_args_list] == [11, 12]
  154. fields = client.merge_spool_extra.call_args.args[1]
  155. assert json.loads(fields["bambu_last_dried_temp"]) == "55"
  156. assert json.loads(fields["bambu_last_dried_hours"]) == "8.0"
  157. assert datetime.fromisoformat(json.loads(fields["bambu_last_dried_at"]))
  158. @pytest.mark.asyncio
  159. @pytest.mark.integration
  160. async def test_one_failing_spool_does_not_skip_the_rest(self, printer_factory, db_session, no_broadcast):
  161. printer = await printer_factory(name="H2D")
  162. db_session.add(Settings(key="spoolman_enabled", value="true"))
  163. for tray, spool_id in ((0, 11), (1, 12)):
  164. db_session.add(
  165. SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=tray, spoolman_spool_id=spool_id)
  166. )
  167. await db_session.commit()
  168. client = MagicMock()
  169. client.merge_spool_extra = AsyncMock(side_effect=[RuntimeError("Spoolman down"), {}])
  170. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
  171. assert await record_drying_cycle(db_session, printer.id, FULL_CYCLE) == 1
  172. assert client.merge_spool_extra.await_count == 2
  173. def test_mapping_reads_the_extra_fields(self):
  174. spool = {
  175. "id": 5,
  176. "filament": {"id": 1, "name": "PLA", "material": "PLA"},
  177. "extra": {
  178. "bambu_last_dried_at": json.dumps("2026-10-06T12:30:00"),
  179. "bambu_last_dried_temp": json.dumps("55"),
  180. "bambu_last_dried_hours": json.dumps("7.5"),
  181. },
  182. }
  183. mapped = _map_spoolman_spool(spool)
  184. assert mapped["last_dried_at"] == "2026-10-06T12:30:00"
  185. assert mapped["last_dried_temp"] == 55
  186. assert mapped["last_dried_hours"] == 7.5
  187. def test_mapping_reads_cleared_and_absent_fields_as_unknown(self):
  188. base = {"id": 5, "filament": {"id": 1, "name": "PLA", "material": "PLA"}}
  189. cleared = {
  190. **base,
  191. "extra": {
  192. "bambu_last_dried_at": json.dumps(""),
  193. "bambu_last_dried_temp": json.dumps(""),
  194. "bambu_last_dried_hours": json.dumps(""),
  195. },
  196. }
  197. for spool in (base, cleared):
  198. mapped = _map_spoolman_spool(spool)
  199. assert mapped["last_dried_at"] is None
  200. assert mapped["last_dried_temp"] is None
  201. assert mapped["last_dried_hours"] is None