test_ams_rest_ws_parity.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. """The REST status and the WebSocket push describe AMS units the same way.
  2. The printer card is seeded from GET /printers/{id}/status and then
  3. shallow-merged with every WebSocket push. The two serialisers were separate
  4. copies, and each field one of them lacked showed up as a UI bug: ``exists``
  5. (#2670), the stalled-countdown flag, and the drying status -- REST left out
  6. ``dry_status``, ``dry_sub_status`` and ``dry_sf_reason``, so a printer that
  7. cannot dry showed an enabled Start button after every page load and poll.
  8. """
  9. import json
  10. from unittest.mock import MagicMock, patch
  11. import pytest
  12. from httpx import AsyncClient
  13. from backend.app.services.bambu_mqtt import PrinterState
  14. from backend.app.services.printer_manager import printer_state_to_dict
  15. RAW = {
  16. "ams": [
  17. {
  18. "id": "0",
  19. "humidity": "4",
  20. "humidity_raw": "31",
  21. "temp": "24.5",
  22. "sn": "AMS0SN",
  23. "sw_ver": "00.00.06.40",
  24. "dry_time": 0,
  25. "dry_status": 0,
  26. "dry_sub_status": 0,
  27. "dry_sf_reason": [1, 8],
  28. "module_type": "ams",
  29. "tray": [
  30. {
  31. "id": "0",
  32. "tray_type": "PLA",
  33. "tray_color": "FF0000FF",
  34. "tray_sub_brands": "PLA Basic",
  35. "tray_info_idx": "GFA00",
  36. "remain": 80,
  37. "k": 0.02,
  38. "cali_idx": 3,
  39. "tag_uid": "0000000000000000",
  40. "state": 11,
  41. "exists": True,
  42. },
  43. # The firmware's whole payload for a physically empty slot.
  44. {"id": "1"},
  45. ],
  46. },
  47. {
  48. "id": "128",
  49. "dry_time": 95,
  50. "dry_status": 2,
  51. "dry_sub_status": 1,
  52. "dry_countdown_stalled": True,
  53. "module_type": "n3s",
  54. "tray": [{"id": "0", "tray_type": "PETG", "tray_color": "00FF00FF", "remain": 50, "state": 11}],
  55. },
  56. ],
  57. "vt_tray": [
  58. {"id": "254", "tray_type": "PETG", "tray_color": "0000FFFF", "remain": 30, "k": 0.03, "cali_idx": 2},
  59. {"id": "255", "tray_type": "", "tag_uid": "0000000000000000", "state": 9},
  60. ],
  61. }
  62. DRYING_TARGETS = {128: {"filament": "PETG", "temp": 65}}
  63. def _state() -> PrinterState:
  64. state = PrinterState()
  65. state.connected = True
  66. state.state = "IDLE"
  67. state.raw_data = json.loads(json.dumps(RAW))
  68. return state
  69. def _without_nulls(value):
  70. """A null field and a missing one read the same on the card, which
  71. replaces these lists whole; REST fills its schema defaults, WS leaves them out."""
  72. if isinstance(value, dict):
  73. return {k: _without_nulls(v) for k, v in value.items() if v is not None}
  74. if isinstance(value, list):
  75. return [_without_nulls(v) for v in value]
  76. return value
  77. async def _rest_status(async_client: AsyncClient, printer_factory) -> dict:
  78. printer = await printer_factory()
  79. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  80. mock_pm.get_status = MagicMock(return_value=_state())
  81. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  82. mock_pm.get_drying_targets = MagicMock(return_value=DRYING_TARGETS)
  83. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  84. assert response.status_code == 200
  85. return response.json()
  86. @pytest.mark.asyncio
  87. @pytest.mark.integration
  88. async def test_rest_reports_the_drying_status(async_client: AsyncClient, printer_factory):
  89. ams = (await _rest_status(async_client, printer_factory))["ams"]
  90. assert ams[0]["dry_sf_reason"] == [1, 8]
  91. assert ams[1]["dry_status"] == 2
  92. assert ams[1]["dry_sub_status"] == 1
  93. @pytest.mark.asyncio
  94. @pytest.mark.integration
  95. async def test_rest_and_websocket_ams_units_match(async_client: AsyncClient, printer_factory):
  96. rest = (await _rest_status(async_client, printer_factory))["ams"]
  97. ws = json.loads(json.dumps(printer_state_to_dict(_state(), drying_targets=DRYING_TARGETS)["ams"]))
  98. assert _without_nulls(rest) == _without_nulls(ws)
  99. @pytest.mark.asyncio
  100. @pytest.mark.integration
  101. async def test_rest_and_websocket_external_spools_match(async_client: AsyncClient, printer_factory):
  102. rest = (await _rest_status(async_client, printer_factory))["vt_tray"]
  103. ws = json.loads(json.dumps(printer_state_to_dict(_state(), drying_targets=DRYING_TARGETS)["vt_tray"]))
  104. assert _without_nulls(rest) == _without_nulls(ws)
  105. @pytest.mark.parametrize(
  106. ("raw", "expected"),
  107. [("24.5", 24.5), (31, 31.0), ("", None), ("--", None), (True, None), (None, None), ("nan", None), ("inf", None)],
  108. )
  109. def test_ams_temperature_is_a_finite_number_or_nothing(raw, expected):
  110. from backend.app.services.printer_manager import _ams_temp
  111. assert _ams_temp(raw) == expected
  112. @pytest.mark.asyncio
  113. @pytest.mark.integration
  114. async def test_rest_survives_the_shapes_websocket_already_tolerated(async_client: AsyncClient, printer_factory):
  115. """A non-dict AMS entry and a dict-shaped vt_tray used to 500 the REST route."""
  116. printer = await printer_factory()
  117. state = _state()
  118. state.raw_data = {
  119. "ams": ["garbage", {"id": "0", "temp": "--", "tray": []}],
  120. "vt_tray": {"id": "254", "tray_type": "PLA", "tray_color": "FFFFFFFF"},
  121. }
  122. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  123. mock_pm.get_status = MagicMock(return_value=state)
  124. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  125. mock_pm.get_drying_targets = MagicMock(return_value={})
  126. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  127. assert response.status_code == 200
  128. body = response.json()
  129. assert [unit["id"] for unit in body["ams"]] == [0]
  130. assert body["ams"][0]["temp"] is None
  131. assert [vt["id"] for vt in body["vt_tray"]] == [254]