|
|
@@ -0,0 +1,180 @@
|
|
|
+"""The temperature alarm reads ams_temp_alarm, end to end (issue #2905).
|
|
|
+
|
|
|
+``test_ams_alarm_gating`` covers the pieces -- the resolution, the latch's
|
|
|
+release check -- but not the thing that consumes them. ``record_ams_history``
|
|
|
+is a no-arg infinite task, which is why the gates already there are tested
|
|
|
+through their extracted helpers instead, and why #2943 said the wiring itself
|
|
|
+was verified by reading rather than by a test.
|
|
|
+
|
|
|
+It can be driven. The loop exits cleanly on ``asyncio.CancelledError``, so a
|
|
|
+fake ``asyncio.sleep`` that recognises the loop's own intervals runs exactly
|
|
|
+one pass and stops: skip the 10 s startup wait, raise at the 300 s
|
|
|
+end-of-pass wait. The 60 s wait belongs to the loop's ``except Exception``
|
|
|
+handler, so intercepting that too turns a swallowed error into a failure with
|
|
|
+a message rather than a test that quietly asserts nothing.
|
|
|
+
|
|
|
+What that buys is the only coverage of the three call sites together: which
|
|
|
+number decides that the alarm fires, and which number the notification quotes.
|
|
|
+Those were separate values before this change and a regression that reverted
|
|
|
+either one would leave every test in the other file passing.
|
|
|
+"""
|
|
|
+
|
|
|
+import asyncio
|
|
|
+from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
+
|
|
|
+import pytest
|
|
|
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
+
|
|
|
+import backend.app.main as main
|
|
|
+from backend.app.models.printer import Printer
|
|
|
+from backend.app.models.settings import Settings
|
|
|
+
|
|
|
+
|
|
|
+def _ams_state_at(temperature: float):
|
|
|
+ """A connected printer with one loaded AMS reading *temperature*.
|
|
|
+
|
|
|
+ ``tray_exist_bits`` matters: the #1619 empty-unit skip drops the alarm
|
|
|
+ before any threshold is consulted, so a unit with no filament would make
|
|
|
+ every assertion below vacuously pass.
|
|
|
+ """
|
|
|
+ state = MagicMock()
|
|
|
+ state.connected = True
|
|
|
+ state.raw_data = {
|
|
|
+ "ams": [
|
|
|
+ {
|
|
|
+ "id": 0,
|
|
|
+ "temp": str(temperature),
|
|
|
+ "humidity": "5",
|
|
|
+ "humidity_raw": "39", # inside the good band — no humidity alarm
|
|
|
+ "tray_exist_bits": "1",
|
|
|
+ "tray": [{"tray_type": "PLA"}],
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ return state
|
|
|
+
|
|
|
+
|
|
|
+async def _run_one_pass(test_engine, temperature: float):
|
|
|
+ """Run record_ams_history exactly once and return the mocked service."""
|
|
|
+ real_sleep = asyncio.sleep
|
|
|
+
|
|
|
+ async def fake_sleep(seconds):
|
|
|
+ if seconds == 10: # startup wait before the first pass
|
|
|
+ return
|
|
|
+ if seconds == main.AMS_HISTORY_INTERVAL: # pass finished cleanly
|
|
|
+ raise asyncio.CancelledError
|
|
|
+ if seconds == 60: # the loop's own except-Exception backoff
|
|
|
+ raise AssertionError("record_ams_history raised; check the warning log")
|
|
|
+ await real_sleep(seconds)
|
|
|
+
|
|
|
+ service = MagicMock()
|
|
|
+ service.on_ams_temperature_high = AsyncMock()
|
|
|
+ service.on_ams_ht_temperature_high = AsyncMock()
|
|
|
+ service.on_ams_humidity_high = AsyncMock()
|
|
|
+
|
|
|
+ maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
|
|
+ cooldown_before = dict(main._ams_alarm_cooldown)
|
|
|
+ counter_before = main._ams_cleanup_counter
|
|
|
+ main._ams_alarm_cooldown.clear()
|
|
|
+ try:
|
|
|
+ with (
|
|
|
+ patch.object(main, "async_session", maker),
|
|
|
+ patch.object(main, "notification_service", service),
|
|
|
+ patch.object(main.printer_manager, "get_status", return_value=_ams_state_at(temperature)),
|
|
|
+ patch.object(main.asyncio, "sleep", fake_sleep),
|
|
|
+ ):
|
|
|
+ await main.record_ams_history()
|
|
|
+ finally:
|
|
|
+ main._ams_alarm_cooldown.clear()
|
|
|
+ main._ams_alarm_cooldown.update(cooldown_before)
|
|
|
+ main._ams_cleanup_counter = counter_before
|
|
|
+ return service
|
|
|
+
|
|
|
+
|
|
|
+async def _printer(db) -> Printer:
|
|
|
+ printer = Printer(name="X2D", serial_number="S-2905", ip_address="1.1.1.1", access_code="c", model="X2D")
|
|
|
+ db.add(printer)
|
|
|
+ await db.commit()
|
|
|
+ return printer
|
|
|
+
|
|
|
+
|
|
|
+async def _set_alarm(db, value: str) -> None:
|
|
|
+ db.add(Settings(key="ams_temp_alarm", value=value))
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_an_install_that_never_set_one_alarms_at_the_display_band(db_session, test_engine):
|
|
|
+ """The upgrade path, which is what makes this shippable without a migration.
|
|
|
+
|
|
|
+ No ams_temp_alarm row: the alarm has to fire where it always did, and quote
|
|
|
+ the number it always quoted.
|
|
|
+ """
|
|
|
+ await _printer(db_session)
|
|
|
+
|
|
|
+ service = await _run_one_pass(test_engine, temperature=50.0)
|
|
|
+
|
|
|
+ service.on_ams_temperature_high.assert_awaited_once()
|
|
|
+ assert service.on_ams_temperature_high.await_args.args[4] == 35.0
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_a_set_threshold_is_both_what_fires_and_what_the_message_quotes(db_session, test_engine):
|
|
|
+ """The two call sites, asserted together.
|
|
|
+
|
|
|
+ A change that fixed the comparison and left the reported value behind would
|
|
|
+ send "50 °C > 35 °C" while the user had asked for 45 — which reads as the
|
|
|
+ old bug rather than as a working alarm.
|
|
|
+ """
|
|
|
+ await _printer(db_session)
|
|
|
+ await _set_alarm(db_session, "45")
|
|
|
+
|
|
|
+ service = await _run_one_pass(test_engine, temperature=50.0)
|
|
|
+
|
|
|
+ service.on_ams_temperature_high.assert_awaited_once()
|
|
|
+ temperature, threshold = service.on_ams_temperature_high.await_args.args[3:5]
|
|
|
+ assert (temperature, threshold) == (50.0, 45.0)
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_ambient_room_heat_below_the_alarm_threshold_sends_nothing(db_session, test_engine):
|
|
|
+ """#2905 itself: 37.7 C in a room without air conditioning, alarm at 45.
|
|
|
+
|
|
|
+ That reading is the one from the report, an hour apart, twice. Nothing is
|
|
|
+ heating and nothing is wrong, so nothing should be sent.
|
|
|
+ """
|
|
|
+ await _printer(db_session)
|
|
|
+ await _set_alarm(db_session, "45")
|
|
|
+
|
|
|
+ service = await _run_one_pass(test_engine, temperature=37.7)
|
|
|
+
|
|
|
+ assert service.on_ams_temperature_high.await_count == 0
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_clearing_the_field_restores_the_old_behaviour(db_session, test_engine):
|
|
|
+ """What the settings page writes when the input is emptied is the literal
|
|
|
+ string "None", not a missing row. It has to land back on the fair value."""
|
|
|
+ await _printer(db_session)
|
|
|
+ await _set_alarm(db_session, "None")
|
|
|
+
|
|
|
+ service = await _run_one_pass(test_engine, temperature=50.0)
|
|
|
+
|
|
|
+ service.on_ams_temperature_high.assert_awaited_once()
|
|
|
+ assert service.on_ams_temperature_high.await_args.args[4] == 35.0
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_an_unusable_value_falls_back_instead_of_silencing_the_alarm(db_session, test_engine):
|
|
|
+ """A stored "nan" parses as a float, and nothing is ever greater than NaN.
|
|
|
+
|
|
|
+ Without the isfinite guard the alarm would go permanently quiet — the
|
|
|
+ failure mode that looks exactly like a working configuration.
|
|
|
+ """
|
|
|
+ await _printer(db_session)
|
|
|
+ await _set_alarm(db_session, "nan")
|
|
|
+
|
|
|
+ service = await _run_one_pass(test_engine, temperature=50.0)
|
|
|
+
|
|
|
+ service.on_ams_temperature_high.assert_awaited_once()
|
|
|
+ assert service.on_ams_temperature_high.await_args.args[4] == 35.0
|