test_ams_drying_latch_persistence.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. """The AMS drying latch has to survive a backend restart (#1802).
  2. Suppression of the high-temperature alarm spans a drying cycle plus the
  3. cool-down after it, which together can run well over twelve hours. Holding that
  4. purely in memory — as the sibling ``_ams_alarm_cooldown`` dict does — meant any
  5. restart partway through resumed alarming about heat the user asked for, so the
  6. latch is stored in the settings table instead.
  7. """
  8. import json
  9. from datetime import datetime, timedelta, timezone
  10. import pytest
  11. from sqlalchemy import select
  12. from backend.app.main import (
  13. AMS_DRYING_GRACE_MINUTES,
  14. AMS_DRYING_LATCH_KEY,
  15. _load_ams_drying_latch,
  16. _save_ams_drying_latch,
  17. )
  18. from backend.app.models.settings import Settings
  19. async def _stored_value(db_session) -> str | None:
  20. result = await db_session.execute(select(Settings).where(Settings.key == AMS_DRYING_LATCH_KEY))
  21. setting = result.scalar_one_or_none()
  22. return setting.value if setting else None
  23. @pytest.mark.asyncio
  24. class TestAmsDryingLatchPersistence:
  25. async def test_round_trip_survives_a_reload(self, db_session):
  26. stamp = datetime.now(timezone.utc) - timedelta(minutes=10)
  27. await _save_ams_drying_latch(db_session, {"1:0": stamp})
  28. await db_session.commit()
  29. # A fresh load is what a restarted backend does on its first pass.
  30. assert await _load_ams_drying_latch(db_session) == {"1:0": stamp}
  31. async def test_no_row_created_when_nothing_ever_dries(self, db_session):
  32. await _save_ams_drying_latch(db_session, {})
  33. await db_session.commit()
  34. assert await _stored_value(db_session) is None
  35. assert await _load_ams_drying_latch(db_session) == {}
  36. async def test_existing_row_is_updated_not_duplicated(self, db_session):
  37. first = datetime.now(timezone.utc) - timedelta(minutes=30)
  38. second = datetime.now(timezone.utc)
  39. await _save_ams_drying_latch(db_session, {"1:0": first})
  40. await db_session.commit()
  41. await _save_ams_drying_latch(db_session, {"1:0": second})
  42. await db_session.commit()
  43. result = await db_session.execute(select(Settings).where(Settings.key == AMS_DRYING_LATCH_KEY))
  44. assert len(result.scalars().all()) == 1
  45. assert await _load_ams_drying_latch(db_session) == {"1:0": second}
  46. async def test_clearing_the_latch_empties_the_row(self, db_session):
  47. await _save_ams_drying_latch(db_session, {"1:0": datetime.now(timezone.utc)})
  48. await db_session.commit()
  49. await _save_ams_drying_latch(db_session, {})
  50. await db_session.commit()
  51. assert await _stored_value(db_session) == "{}"
  52. assert await _load_ams_drying_latch(db_session) == {}
  53. async def test_multiple_units_are_tracked_independently(self, db_session):
  54. now = datetime.now(timezone.utc)
  55. latch = {"1:0": now - timedelta(minutes=5), "1:1": now, "2:128": now - timedelta(minutes=15)}
  56. await _save_ams_drying_latch(db_session, latch)
  57. await db_session.commit()
  58. assert await _load_ams_drying_latch(db_session) == latch
  59. async def test_entries_past_the_grace_cap_are_dropped_on_load(self, db_session):
  60. now = datetime.now(timezone.utc)
  61. fresh = now - timedelta(minutes=5)
  62. stale = now - timedelta(minutes=AMS_DRYING_GRACE_MINUTES + 30)
  63. await _save_ams_drying_latch(db_session, {"1:0": fresh, "9:3": stale})
  64. await db_session.commit()
  65. # The stale one would expire on its next visit anyway; dropping it here
  66. # keeps rows for deleted printers from accumulating forever.
  67. assert await _load_ams_drying_latch(db_session) == {"1:0": fresh}
  68. async def test_wildly_future_stamps_are_dropped(self, db_session):
  69. # A box whose clock jumps backwards (a Pi coming up before NTP) would
  70. # otherwise hold the alarm suppressed until real time caught up.
  71. future = datetime.now(timezone.utc) + timedelta(hours=6)
  72. await _save_ams_drying_latch(db_session, {"1:0": future})
  73. await db_session.commit()
  74. assert await _load_ams_drying_latch(db_session) == {}
  75. async def test_near_future_stamps_are_clamped_to_now(self, db_session):
  76. # Small backwards skew survives as a latch, but must not sit ahead of
  77. # now: suppression is measured as now minus the stamp, so a future one
  78. # would run for the skew on top of the cap instead of the cap alone.
  79. before = datetime.now(timezone.utc)
  80. await _save_ams_drying_latch(db_session, {"1:0": before + timedelta(minutes=30)})
  81. await db_session.commit()
  82. loaded = await _load_ams_drying_latch(db_session)
  83. assert set(loaded) == {"1:0"}
  84. assert before <= loaded["1:0"] <= datetime.now(timezone.utc)
  85. async def test_corrupt_row_reads_as_no_latch(self, db_session):
  86. db_session.add(Settings(key=AMS_DRYING_LATCH_KEY, value="{not json"))
  87. await db_session.commit()
  88. # Degrades to the pre-#1802 behaviour rather than crashing the recorder.
  89. assert await _load_ams_drying_latch(db_session) == {}
  90. async def test_non_object_json_reads_as_no_latch(self, db_session):
  91. db_session.add(Settings(key=AMS_DRYING_LATCH_KEY, value="[1, 2, 3]"))
  92. await db_session.commit()
  93. assert await _load_ams_drying_latch(db_session) == {}
  94. async def test_unparseable_stamps_are_skipped_individually(self, db_session):
  95. good = datetime.now(timezone.utc) - timedelta(minutes=3)
  96. db_session.add(
  97. Settings(
  98. key=AMS_DRYING_LATCH_KEY,
  99. value=json.dumps({"1:0": good.isoformat(), "1:1": "yesterday"}),
  100. )
  101. )
  102. await db_session.commit()
  103. assert await _load_ams_drying_latch(db_session) == {"1:0": good}
  104. async def test_naive_stamps_are_read_as_utc(self, db_session):
  105. # SQLite hands back naive datetimes elsewhere in the app, so a hand-edited
  106. # or migrated value without an offset must not raise on comparison.
  107. naive = (datetime.now(timezone.utc) - timedelta(minutes=7)).replace(tzinfo=None)
  108. db_session.add(Settings(key=AMS_DRYING_LATCH_KEY, value=json.dumps({"1:0": naive.isoformat()})))
  109. await db_session.commit()
  110. loaded = await _load_ams_drying_latch(db_session)
  111. assert loaded == {"1:0": naive.replace(tzinfo=timezone.utc)}