test_hms_notified_restart_3486.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. """HMS notifications survive a restart without being sent again (#3486).
  2. Printers keep faults in ``hms[]`` long after they happened. The set of faults
  3. already notified lived only in memory, so every Bambuddy restart sent each
  4. still-listed fault once more. And the title's category came from a module table
  5. that called system faults "Nozzle/Extruder" and AMS Lite faults "Chamber".
  6. """
  7. from types import SimpleNamespace
  8. from unittest.mock import AsyncMock, patch
  9. import pytest
  10. from sqlalchemy import select
  11. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  12. from backend.app import main as main_module
  13. from backend.app.main import (
  14. _hms_error_title,
  15. _load_notified_hms_faults,
  16. _take_new_hms_faults_and_save,
  17. )
  18. from backend.app.models.hms_notified_fault import HmsNotifiedFault
  19. from backend.app.models.printer import Printer
  20. def _fault(full_code: str):
  21. return SimpleNamespace(
  22. full_code=full_code,
  23. attr=int(full_code[:8], 16),
  24. code=f"0x{full_code[8:]}",
  25. severity=2,
  26. description="Described.",
  27. actions=[],
  28. )
  29. TOOLHEAD = _fault("0300010000010008")
  30. POWER_LOSS = _fault("0500020000020001")
  31. @pytest.fixture
  32. async def printer_id(db_session):
  33. printer = Printer(name="P", serial_number="00M09A000000001", ip_address="192.168.1.100", access_code="12345678")
  34. db_session.add(printer)
  35. await db_session.commit()
  36. pid = printer.id
  37. main_module._notified_hms_errors.pop(pid, None)
  38. main_module._hms_print_state.pop(pid, None)
  39. main_module._hms_forget_when_gone.pop(pid, None)
  40. yield pid
  41. main_module._notified_hms_errors.pop(pid, None)
  42. main_module._hms_print_state.pop(pid, None)
  43. main_module._hms_forget_when_gone.pop(pid, None)
  44. @pytest.fixture(autouse=True)
  45. def app_session(test_engine):
  46. """The service opens its own sessions; point them at the test database."""
  47. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  48. with patch("backend.app.services.hms_notified.async_session", maker):
  49. yield
  50. def _restart(printer_id: int) -> None:
  51. """What a restart does to the in-memory tracking."""
  52. main_module._notified_hms_errors.pop(printer_id, None)
  53. main_module._hms_print_state.pop(printer_id, None)
  54. main_module._hms_forget_when_gone.pop(printer_id, None)
  55. async def _stored(db_session, printer_id: int) -> set[str]:
  56. rows = await db_session.execute(select(HmsNotifiedFault.fault_key).where(HmsNotifiedFault.printer_id == printer_id))
  57. return set(rows.scalars())
  58. @pytest.mark.asyncio
  59. class TestRestart:
  60. async def test_a_fault_still_listed_is_not_sent_again(self, printer_id):
  61. assert await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD, POWER_LOSS], "RUNNING") == [
  62. TOOLHEAD,
  63. POWER_LOSS,
  64. ]
  65. _restart(printer_id)
  66. await _load_notified_hms_faults()
  67. assert await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD, POWER_LOSS], "RUNNING") == []
  68. async def test_a_new_fault_after_the_restart_is_still_sent(self, printer_id):
  69. await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD], "RUNNING")
  70. _restart(printer_id)
  71. await _load_notified_hms_faults()
  72. assert await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD, POWER_LOSS], "RUNNING") == [POWER_LOSS]
  73. async def test_without_the_stored_set_a_restart_sends_again(self, printer_id):
  74. # The failure itself: nothing loaded, everything listed is new.
  75. await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD], "RUNNING")
  76. _restart(printer_id)
  77. assert await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD], "RUNNING") == [TOOLHEAD]
  78. async def test_a_forgotten_fault_leaves_the_stored_set(self, printer_id, db_session):
  79. await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD, POWER_LOSS], "RUNNING")
  80. assert await _stored(db_session, printer_id) == {"0300010000010008", "0500020000020001"}
  81. # A print state change forgets a fault as soon as it is gone.
  82. await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD], "FINISH")
  83. db_session.expire_all()
  84. assert await _stored(db_session, printer_id) == {"0300010000010008"}
  85. _restart(printer_id)
  86. await _load_notified_hms_faults()
  87. assert await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD, POWER_LOSS], "FINISH") == [POWER_LOSS]
  88. async def test_a_failed_write_does_not_hold_back_the_notification(self, printer_id):
  89. with patch(
  90. "backend.app.services.hms_notified.save_notified_hms_faults",
  91. AsyncMock(side_effect=RuntimeError("database is locked")),
  92. ):
  93. assert await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD], "RUNNING") == [TOOLHEAD]
  94. async def test_a_failed_load_starts_empty(self, printer_id):
  95. with patch(
  96. "backend.app.services.hms_notified.load_notified_hms_faults",
  97. AsyncMock(side_effect=RuntimeError("no such table")),
  98. ):
  99. await _load_notified_hms_faults()
  100. assert printer_id not in main_module._notified_hms_errors
  101. class TestTitle:
  102. @pytest.mark.parametrize(
  103. ("module", "title"),
  104. [
  105. # USB, MQTT and file errors were titled "Nozzle/Extruder Error".
  106. (0x05, "System Error"),
  107. # Cameras and lidar, not motion.
  108. (0x0C, "Camera Error"),
  109. # An A1 mini's AMS Lite runouts were titled "Chamber Error".
  110. (0x12, "AMS Lite Error"),
  111. (0x03, "Print/Task Error"),
  112. (0x07, "AMS/Filament Error"),
  113. (0x18, "AMS-HT Error"),
  114. (0x2A, "AMS 3H Error"),
  115. (0x1A, "Tool Changer Error"),
  116. # Unknown modules get a plain title rather than a hex number.
  117. (0x7F, "Printer Error"),
  118. ],
  119. )
  120. def test_the_title_names_the_right_subsystem(self, module, title):
  121. assert _hms_error_title(module) == title