test_ams_temp_alarm_dispatch_2905.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. """The temperature alarm reads ams_temp_alarm, end to end (issue #2905).
  2. ``test_ams_alarm_gating`` covers the pieces -- the resolution, the latch's
  3. release check -- but not the thing that consumes them. ``record_ams_history``
  4. is a no-arg infinite task, which is why the gates already there are tested
  5. through their extracted helpers instead, and why #2943 said the wiring itself
  6. was verified by reading rather than by a test.
  7. It can be driven. The loop exits cleanly on ``asyncio.CancelledError``, so a
  8. fake ``asyncio.sleep`` that recognises the loop's own intervals runs exactly
  9. one pass and stops: skip the 10 s startup wait, raise at the 300 s
  10. end-of-pass wait. The 60 s wait belongs to the loop's ``except Exception``
  11. handler, so intercepting that too turns a swallowed error into a failure with
  12. a message rather than a test that quietly asserts nothing.
  13. What that buys is the only coverage of the three call sites together: which
  14. number decides that the alarm fires, and which number the notification quotes.
  15. Those were separate values before this change and a regression that reverted
  16. either one would leave every test in the other file passing.
  17. """
  18. import asyncio
  19. from unittest.mock import AsyncMock, MagicMock, patch
  20. import pytest
  21. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  22. import backend.app.main as main
  23. from backend.app.models.printer import Printer
  24. from backend.app.models.settings import Settings
  25. def _ams_state_at(temperature: float):
  26. """A connected printer with one loaded AMS reading *temperature*.
  27. ``tray_exist_bits`` matters: the #1619 empty-unit skip drops the alarm
  28. before any threshold is consulted, so a unit with no filament would make
  29. every assertion below vacuously pass.
  30. """
  31. state = MagicMock()
  32. state.connected = True
  33. state.raw_data = {
  34. "ams": [
  35. {
  36. "id": 0,
  37. "temp": str(temperature),
  38. "humidity": "5",
  39. "humidity_raw": "39", # inside the good band — no humidity alarm
  40. "tray_exist_bits": "1",
  41. "tray": [{"tray_type": "PLA"}],
  42. }
  43. ]
  44. }
  45. return state
  46. async def _run_one_pass(test_engine, temperature: float):
  47. """Run record_ams_history exactly once and return the mocked service."""
  48. real_sleep = asyncio.sleep
  49. async def fake_sleep(seconds):
  50. if seconds == 10: # startup wait before the first pass
  51. return
  52. if seconds == main.AMS_HISTORY_INTERVAL: # pass finished cleanly
  53. raise asyncio.CancelledError
  54. if seconds == 60: # the loop's own except-Exception backoff
  55. raise AssertionError("record_ams_history raised; check the warning log")
  56. await real_sleep(seconds)
  57. service = MagicMock()
  58. service.on_ams_temperature_high = AsyncMock()
  59. service.on_ams_ht_temperature_high = AsyncMock()
  60. service.on_ams_humidity_high = AsyncMock()
  61. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  62. cooldown_before = dict(main._ams_alarm_cooldown)
  63. counter_before = main._ams_cleanup_counter
  64. main._ams_alarm_cooldown.clear()
  65. try:
  66. with (
  67. patch.object(main, "async_session", maker),
  68. patch.object(main, "notification_service", service),
  69. patch.object(main.printer_manager, "get_status", return_value=_ams_state_at(temperature)),
  70. patch.object(main.asyncio, "sleep", fake_sleep),
  71. ):
  72. await main.record_ams_history()
  73. finally:
  74. main._ams_alarm_cooldown.clear()
  75. main._ams_alarm_cooldown.update(cooldown_before)
  76. main._ams_cleanup_counter = counter_before
  77. return service
  78. async def _printer(db) -> Printer:
  79. printer = Printer(name="X2D", serial_number="S-2905", ip_address="1.1.1.1", access_code="c", model="X2D")
  80. db.add(printer)
  81. await db.commit()
  82. return printer
  83. async def _set_alarm(db, value: str) -> None:
  84. db.add(Settings(key="ams_temp_alarm", value=value))
  85. await db.commit()
  86. @pytest.mark.asyncio
  87. async def test_an_install_that_never_set_one_alarms_at_the_display_band(db_session, test_engine):
  88. """The upgrade path, which is what makes this shippable without a migration.
  89. No ams_temp_alarm row: the alarm has to fire where it always did, and quote
  90. the number it always quoted.
  91. """
  92. await _printer(db_session)
  93. service = await _run_one_pass(test_engine, temperature=50.0)
  94. service.on_ams_temperature_high.assert_awaited_once()
  95. assert service.on_ams_temperature_high.await_args.args[4] == 35.0
  96. @pytest.mark.asyncio
  97. async def test_a_set_threshold_is_both_what_fires_and_what_the_message_quotes(db_session, test_engine):
  98. """The two call sites, asserted together.
  99. A change that fixed the comparison and left the reported value behind would
  100. send "50 °C > 35 °C" while the user had asked for 45 — which reads as the
  101. old bug rather than as a working alarm.
  102. """
  103. await _printer(db_session)
  104. await _set_alarm(db_session, "45")
  105. service = await _run_one_pass(test_engine, temperature=50.0)
  106. service.on_ams_temperature_high.assert_awaited_once()
  107. temperature, threshold = service.on_ams_temperature_high.await_args.args[3:5]
  108. assert (temperature, threshold) == (50.0, 45.0)
  109. @pytest.mark.asyncio
  110. async def test_ambient_room_heat_below_the_alarm_threshold_sends_nothing(db_session, test_engine):
  111. """#2905 itself: 37.7 C in a room without air conditioning, alarm at 45.
  112. That reading is the one from the report, an hour apart, twice. Nothing is
  113. heating and nothing is wrong, so nothing should be sent.
  114. """
  115. await _printer(db_session)
  116. await _set_alarm(db_session, "45")
  117. service = await _run_one_pass(test_engine, temperature=37.7)
  118. assert service.on_ams_temperature_high.await_count == 0
  119. @pytest.mark.asyncio
  120. async def test_clearing_the_field_restores_the_old_behaviour(db_session, test_engine):
  121. """What the settings page writes when the input is emptied is the literal
  122. string "None", not a missing row. It has to land back on the fair value."""
  123. await _printer(db_session)
  124. await _set_alarm(db_session, "None")
  125. service = await _run_one_pass(test_engine, temperature=50.0)
  126. service.on_ams_temperature_high.assert_awaited_once()
  127. assert service.on_ams_temperature_high.await_args.args[4] == 35.0
  128. @pytest.mark.asyncio
  129. async def test_an_unusable_value_falls_back_instead_of_silencing_the_alarm(db_session, test_engine):
  130. """A stored "nan" parses as a float, and nothing is ever greater than NaN.
  131. Without the isfinite guard the alarm would go permanently quiet — the
  132. failure mode that looks exactly like a working configuration.
  133. """
  134. await _printer(db_session)
  135. await _set_alarm(db_session, "nan")
  136. service = await _run_one_pass(test_engine, temperature=50.0)
  137. service.on_ams_temperature_high.assert_awaited_once()
  138. assert service.on_ams_temperature_high.await_args.args[4] == 35.0