test_hms_actions.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. """Tests for HMS-action lookup and the MQTT dispatcher in execute_hms_action.
  2. The lookup tests confirm the bundled catalog round-trips correctly. The
  3. dispatcher tests are payload-shape contracts — wrong shape sends a bogus
  4. command to the printer, which is the failure mode this PR is most exposed to,
  5. so each HMSAction case publishes the expected JSON.
  6. """
  7. import json
  8. from unittest.mock import MagicMock
  9. import pytest
  10. from backend.app.services.bambu_mqtt import BambuMQTTClient
  11. from backend.app.services.hms_actions import (
  12. HMSAction,
  13. get_actions_for_error_code,
  14. )
  15. class TestActionLookup:
  16. def test_known_a1_error_returns_actions(self):
  17. # 03W is the A1 model code; 03008070 is "Heat the nozzle…" and Bambu's
  18. # catalog lists CHECK_ASSISTANT for it.
  19. actions = get_actions_for_error_code("03W", "03008070")
  20. assert isinstance(actions, list)
  21. assert len(actions) > 0
  22. for a in actions:
  23. assert isinstance(a, str)
  24. def test_unknown_device_returns_empty_list(self):
  25. assert get_actions_for_error_code("ZZZ", "03008070") == []
  26. def test_unknown_error_returns_empty_list(self):
  27. # Real model code, made-up error.
  28. assert get_actions_for_error_code("03W", "DEADBEEF") == []
  29. def test_underscore_form_does_not_match(self):
  30. # Caller is responsible for stripping the `_` before lookup. Guards
  31. # against accidental rewires that pass the underscore form.
  32. assert get_actions_for_error_code("03W", "0300_8070") == []
  33. def test_action_enum_values_are_uppercase_strings(self):
  34. # The catalog stores actions verbatim from BambuStudio. Drift here
  35. # silently breaks the dispatcher's `match` because StrEnum compares
  36. # by value.
  37. assert HMSAction.RESUME_PRINTING == "RESUME_PRINTING"
  38. assert HMSAction.CANCLE == "CANCLE" # sic — kept from BambuStudio
  39. class TestExecuteHmsActionDispatch:
  40. """Each case in the `match` publishes a specific JSON shape. These tests
  41. pin those shapes so silent regressions surface as test failures, not as
  42. a printer receiving a malformed command on a live print.
  43. """
  44. @pytest.fixture
  45. def client(self):
  46. c = BambuMQTTClient(
  47. ip_address="192.168.1.100",
  48. serial_number="03W-TEST",
  49. access_code="12345678",
  50. )
  51. c._client = MagicMock()
  52. c.state.connected = True
  53. return c
  54. def _published_commands(self, client):
  55. """Return the list of `print`/`system` command dicts from publish calls,
  56. skipping the `pushing.pushall` echoes that follow every action."""
  57. out = []
  58. for call in client._client.publish.call_args_list:
  59. _topic, payload = call.args[0], call.args[1]
  60. data = json.loads(payload)
  61. if "pushing" in data:
  62. continue
  63. out.append(data)
  64. return out
  65. def test_returns_false_when_disconnected(self, client):
  66. client.state.connected = False
  67. assert client.execute_hms_action("03008070", HMSAction.OK_BUTTON) is False
  68. client._client.publish.assert_not_called()
  69. def test_returns_false_on_unknown_action(self, client):
  70. assert client.execute_hms_action("03008070", "DOES_NOT_EXIST") is False
  71. # No printer command, but the publish-list check tolerates the pushall
  72. # tail — just confirm no command went out by inspecting the helper.
  73. assert self._published_commands(client) == []
  74. def test_resume_carries_err_param_and_job_id(self, client):
  75. ok = client.execute_hms_action("03008070", HMSAction.RESUME_PRINTING, job_id="task-42")
  76. assert ok is True
  77. cmds = self._published_commands(client)
  78. assert cmds == [
  79. {
  80. "print": {
  81. "command": "resume",
  82. "err": "03008070",
  83. "param": "reserve",
  84. "job_id": "task-42",
  85. "sequence_id": "0",
  86. }
  87. }
  88. ]
  89. def test_proceed_falls_through_to_resume(self, client):
  90. client.execute_hms_action("03008070", HMSAction.PROCEED, job_id="task-1")
  91. cmds = self._published_commands(client)
  92. assert cmds[0]["print"]["command"] == "resume"
  93. assert cmds[0]["print"]["err"] == "03008070"
  94. def test_stop_carries_err_and_job_id(self, client):
  95. client.execute_hms_action("03008070", HMSAction.STOP_PRINTING, job_id="task-1")
  96. cmds = self._published_commands(client)
  97. assert cmds[0]["print"]["command"] == "stop"
  98. assert cmds[0]["print"]["job_id"] == "task-1"
  99. def test_ignore_resume_uses_idle_ignore_type_zero(self, client):
  100. client.execute_hms_action("03008070", HMSAction.IGNORE_RESUME)
  101. cmds = self._published_commands(client)
  102. assert cmds[0] == {
  103. "print": {
  104. "command": "idle_ignore",
  105. "err": "03008070",
  106. "type": 0,
  107. "sequence_id": "0",
  108. }
  109. }
  110. def test_dont_remind_uses_idle_ignore_type_one(self, client):
  111. # DONT_REMIND_NEXT_TIME and IGNORE_NO_REMINDER_NEXT_TIME are the
  112. # persistent variants — Bambu hides the warning for future prints.
  113. client.execute_hms_action("03008070", HMSAction.DONT_REMIND_NEXT_TIME)
  114. cmds = self._published_commands(client)
  115. assert cmds[0]["print"]["command"] == "idle_ignore"
  116. assert cmds[0]["print"]["type"] == 1
  117. def test_filament_extruded_sends_ams_done(self, client):
  118. client.execute_hms_action("07008029", HMSAction.FILAMENT_EXTRUDED)
  119. cmds = self._published_commands(client)
  120. assert cmds[0] == {"print": {"command": "ams_control", "param": "done", "sequence_id": "0"}}
  121. def test_retry_sends_ams_resume(self, client):
  122. client.execute_hms_action("07008029", HMSAction.RETRY_FILAMENT_EXTRUDED)
  123. cmds = self._published_commands(client)
  124. assert cmds[0]["print"]["param"] == "resume"
  125. assert cmds[0]["print"]["command"] == "ams_control"
  126. def test_abort_sends_ams_abort(self, client):
  127. client.execute_hms_action("07008029", HMSAction.ABORT)
  128. cmds = self._published_commands(client)
  129. assert cmds[0]["print"]["param"] == "abort"
  130. def test_ok_button_sends_bare_clean_print_error(self, client):
  131. # Matches the existing `clear_hms_errors` shape — no `print_error` body
  132. # field, which the original PR mistakenly added.
  133. client.execute_hms_action("03008070", HMSAction.OK_BUTTON)
  134. cmds = self._published_commands(client)
  135. assert cmds[0] == {"print": {"command": "clean_print_error", "sequence_id": "0"}}
  136. def test_dbl_check_ok_sends_clean_then_uiop_close(self, client):
  137. client.execute_hms_action("03008070", HMSAction.DBL_CHECK_OK)
  138. cmds = self._published_commands(client)
  139. assert len(cmds) == 2
  140. assert cmds[0]["print"]["command"] == "clean_print_error"
  141. assert cmds[1]["system"]["command"] == "uiop"
  142. # `err` is the already-string short code, NOT `f"{x:08X}"` against a
  143. # str (which would TypeError on the old code path).
  144. assert cmds[1]["system"]["err"] == "03008070"
  145. def test_uiop_close_uppercases_lowercase_input(self, client):
  146. # Frontend may send the short code in either case; we normalise.
  147. client.execute_hms_action("0300abcd", HMSAction.DBL_CHECK_OK)
  148. cmds = self._published_commands(client)
  149. assert cmds[1]["system"]["err"] == "0300ABCD"
  150. def test_dbl_check_resume_is_plain_resume(self, client):
  151. # No err/job_id — explicitly different from RESUME_PRINTING.
  152. client.execute_hms_action("03008070", HMSAction.DBL_CHECK_RESUME)
  153. cmds = self._published_commands(client)
  154. assert cmds[0] == {"print": {"command": "resume", "param": "", "sequence_id": "0"}}
  155. assert "err" not in cmds[0]["print"]
  156. def test_refresh_nozzle(self, client):
  157. client.execute_hms_action("03008070", HMSAction.REFRESH_NOZZLE)
  158. cmds = self._published_commands(client)
  159. assert cmds[0] == {"print": {"command": "refresh_nozzle", "sequence_id": "0"}}
  160. def test_turn_off_fire_alarm_sends_buzzer_off(self, client):
  161. client.execute_hms_action("03008044", HMSAction.TURN_OFF_FIRE_ALARM)
  162. cmds = self._published_commands(client)
  163. assert cmds[0]["print"]["command"] == "buzzer_ctrl"
  164. assert cmds[0]["print"]["mode"] == 0
  165. def test_stop_drying_sends_auto_stop_ams_dry(self, client):
  166. client.execute_hms_action("07008017", HMSAction.STOP_DRYING)
  167. cmds = self._published_commands(client)
  168. assert cmds[0]["print"]["command"] == "auto_stop_ams_dry"
  169. def test_disable_purification_sends_close_air_filt(self, client):
  170. client.execute_hms_action("03008063", HMSAction.DISABLE_PURIFICATION)
  171. cmds = self._published_commands(client)
  172. assert cmds[0]["print"]["command"] == "close_air_filt"
  173. @pytest.mark.parametrize(
  174. "action",
  175. [
  176. HMSAction.CHECK_ASSISTANT,
  177. HMSAction.JUMP_TO_LIVEVIEW,
  178. HMSAction.OK_JUMP_RACK,
  179. HMSAction.REMOVE_CLOSE_BTN,
  180. HMSAction.LOAD_VIRTUAL_TRAY,
  181. HMSAction.CANCLE,
  182. HMSAction.DBL_CHECK_CANCEL,
  183. ],
  184. )
  185. def test_ui_only_actions_publish_nothing(self, client, action):
  186. # These actions exist for parity with BambuStudio's modal but have no
  187. # MQTT counterpart — the printer's own screen drives them.
  188. assert client.execute_hms_action("03008070", action) is True
  189. assert self._published_commands(client) == []
  190. def test_every_publish_is_followed_by_pushall(self, client):
  191. # The dispatcher pairs every command with a `pushing.pushall` echo so
  192. # the state stream refreshes on the next tick. Regression guard.
  193. client.execute_hms_action("03008070", HMSAction.RESUME_PRINTING)
  194. payloads = [json.loads(c.args[1]) for c in client._client.publish.call_args_list]
  195. assert any("pushing" in p for p in payloads)