test_print_control.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. """One way to stop a print on a user's behalf.
  2. The printer card, the queue and the two webhook routes each used to send the
  3. stop and set the "stopped by the user" mark themselves, and one of them forgot
  4. the mark: the print then ended as failed, with a "print failed" notification
  5. and, on some printers, a mislabelled error such as a layer shift. They now all
  6. call print_control.stop_print_by_user, and only it (and the automatic kill
  7. switch, which is not a user stop) may send a stop command.
  8. """
  9. import ast
  10. from pathlib import Path
  11. from unittest.mock import patch
  12. import pytest
  13. from backend.app.services.print_control import stop_print_by_user
  14. BACKEND_APP = Path(__file__).resolve().parents[2] / "app"
  15. @pytest.fixture
  16. def marks():
  17. from backend.app.main import _user_stopped_printers
  18. _user_stopped_printers.clear()
  19. yield _user_stopped_printers
  20. _user_stopped_printers.clear()
  21. def test_a_sent_stop_marks_the_printer(marks):
  22. with patch("backend.app.services.print_control.printer_manager.stop_print", return_value=True) as send:
  23. assert stop_print_by_user(4) is True
  24. send.assert_called_once_with(4)
  25. assert 4 in marks
  26. def test_an_unsent_stop_leaves_no_mark(marks):
  27. with patch("backend.app.services.print_control.printer_manager.stop_print", return_value=False):
  28. assert stop_print_by_user(4) is False
  29. assert 4 not in marks
  30. def test_the_queue_can_mark_an_unsent_stop(marks):
  31. """An offline printer that later reports the job as failed must still read
  32. it as cancelled by the user (the queue closes the job at once)."""
  33. with patch("backend.app.services.print_control.printer_manager.stop_print", return_value=False):
  34. assert stop_print_by_user(4, mark_when_unsent=True) is False
  35. assert 4 in marks
  36. def test_a_failing_send_raises_and_marks_only_when_asked(marks):
  37. with patch("backend.app.services.print_control.printer_manager.stop_print", side_effect=OSError("mqtt down")):
  38. with pytest.raises(OSError):
  39. stop_print_by_user(4)
  40. assert 4 not in marks
  41. with pytest.raises(OSError):
  42. stop_print_by_user(4, mark_when_unsent=True)
  43. assert 4 in marks
  44. @pytest.mark.parametrize(
  45. ("state", "marked"),
  46. [
  47. ("RUNNING", True),
  48. ("PAUSE", True),
  49. ("PREPARE", True),
  50. ("IDLE", False),
  51. ("FINISH", False),
  52. # Before the first report after a (re)connect, and after a power-off.
  53. ("unknown", True),
  54. ("", True),
  55. (None, True),
  56. ],
  57. )
  58. def test_a_stop_on_an_idle_printer_leaves_no_mark(marks, state, marked):
  59. """USP/I4: the mark is cleared only by the next print's start, which is
  60. missed when a new MQTT client first sees that print mid-way; its later
  61. failure was then reported as the user's cancel. A printer in an unknown
  62. state is still marked."""
  63. from types import SimpleNamespace
  64. status = SimpleNamespace(state=state) if state is not None else None
  65. with (
  66. patch("backend.app.services.print_control.printer_manager.stop_print", return_value=True) as send,
  67. patch("backend.app.services.print_control.printer_manager.get_status", return_value=status),
  68. ):
  69. assert stop_print_by_user(4) is True
  70. send.assert_called_once_with(4)
  71. assert (4 in marks) is marked
  72. def test_the_queue_marks_an_idle_printer_too(marks):
  73. """The queue stops a job it knows it started; a printer that reads idle
  74. there may simply not have reported yet."""
  75. from types import SimpleNamespace
  76. with (
  77. patch("backend.app.services.print_control.printer_manager.stop_print", return_value=True),
  78. patch(
  79. "backend.app.services.print_control.printer_manager.get_status", return_value=SimpleNamespace(state="IDLE")
  80. ),
  81. ):
  82. stop_print_by_user(4, mark_when_unsent=True)
  83. assert 4 in marks
  84. # Files allowed to send a stop command themselves: the shared function, the
  85. # printer manager and MQTT client that implement it, and main.py's kill switch,
  86. # which stops unauthorised prints automatically (not a user stop, no mark).
  87. ALLOWED = {
  88. "services/print_control.py",
  89. "services/printer_manager.py",
  90. "services/bambu_mqtt.py",
  91. "main.py",
  92. }
  93. def test_no_other_code_sends_a_stop():
  94. """Catches a new ``.stop_print(`` call. It cannot see a stop sent as a raw
  95. MQTT payload (the HMS dialog's "Stop Printing" in bambu_mqtt.py, whose
  96. route marks the stop itself); such a path must call mark_stopped_by_user."""
  97. offenders = []
  98. for path in sorted(BACKEND_APP.rglob("*.py")):
  99. rel = path.relative_to(BACKEND_APP).as_posix()
  100. if rel in ALLOWED:
  101. continue
  102. for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
  103. if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "stop_print":
  104. offenders.append(f"{rel}:{node.lineno}")
  105. assert not offenders, (
  106. "Stop a print through services.print_control.stop_print_by_user, which also sets the "
  107. f"stopped-by-user mark: {offenders}"
  108. )