wake-alarm/wake_alarm/tests/test_alarm_session_refire.py
Krzysztof kuhy Rudnicki c1dbdcda22 Grade against 3.14 too, not just run on it
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10/3.11 -- three tools judging an interpreter
nothing runs any more, and ruff withholding the pyupgrade fixes that only
become safe at the newer target. `pin_ci_python.py` rewrites these three knobs.

What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), `socket.timeout` where `TimeoutError` is (UP041), and
ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes
`except A, B:`. That makes the source 3.14-only at the syntax level, which is
what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately
over holding ruff a release back (2026-08-29).

ruff, ruff-format, mypy --strict and pylint all clean at the new target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:39:33 +02:00

135 lines
5.0 KiB
Python

"""Tests for the re-fire decision driven by the session record.
Split from ``test_alarm_session.py`` for the 250-line cap. The header is copied
whole: without the autouse ``session_file`` fixture these tests would read and
write the *production* record, which the next real trigger tick would act on.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import json
from typing import TYPE_CHECKING
import pytest
from wake_alarm import _alarm_session
from wake_alarm._alarm_session import (
STATE_RINGING,
finish_session,
should_refire,
start_session,
unfinished_session_age,
)
if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture(autouse=True)
def session_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point the record at a temporary file, never the real one."""
path = tmp_path / "alarm_session.json"
monkeypatch.setattr(_alarm_session, "ALARM_SESSION_FILE", path)
return path
class TestShouldRefire:
"""The decision _trigger acts on."""
def test_no_record_does_not_refire(self) -> None:
"""The ordinary case: nothing was cut short."""
assert should_refire() is False
def test_ringing_record_refires(self) -> None:
"""The whole point: a power-off mid-alarm rings again."""
start_session("wake-20260827-0700")
assert should_refire() is True
def test_finished_record_does_not_refire(self) -> None:
"""A dismissed alarm stays dismissed."""
start_session("wake-20260827-0700")
finish_session()
assert should_refire() is False
def test_stale_record_does_not_refire(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Bounded on purpose.
An alarm that died before dismissal leaves a record nothing will ever
clear; unbounded, this would restart the alarm every minute forever.
"""
start_session("wake-20260827-0700")
much_later = datetime.now(tz=UTC).astimezone() + timedelta(days=1)
with caplog.at_level("INFO"):
assert should_refire(now=much_later) is False
assert "Ignoring an unfinished alarm session" in caplog.text
def test_a_backward_clock_jump_still_refires(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Fails *closed*, which is the whole point of the record.
NTP correcting after a resume moves the clock backwards, leaving the
record stamped in the future. Discarding it there would mean: alarm
fires on a fast clock, clock is corrected, and the power-off escape
quietly works again -- on a machine that hibernates nightly, i.e. the
exact scenario this record exists to close.
"""
start_session("wake-20260827-0700")
corrected = datetime.now(tz=UTC).astimezone() - timedelta(minutes=5)
with caplog.at_level("INFO"):
assert should_refire(now=corrected) is True
assert "in the future" in caplog.text
def test_a_wildly_future_record_is_ignored(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Bounded, so a broken RTC cannot create an endless re-fire."""
start_session("wake-20260827-0700")
way_earlier = datetime.now(tz=UTC).astimezone() - timedelta(hours=6)
with caplog.at_level("WARNING"):
assert should_refire(now=way_earlier) is False
assert "beyond the" in caplog.text
def test_missing_timestamp_is_ignored(
self,
session_file: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A record with no usable fired_at cannot be aged, so it is dropped."""
monkeypatch.setattr(_alarm_session, "verify_entry_hmac", lambda _e: True)
session_file.write_text(json.dumps({"state": STATE_RINGING}))
with caplog.at_level("WARNING"):
assert unfinished_session_age() is None
assert "no usable fired_at" in caplog.text
@pytest.mark.parametrize(
"fired_at",
[
"not-a-timestamp",
# Naive: without an offset the elapsed-time comparison is
# meaningless, which is exactly the ambiguity this record avoids.
"2026-08-27T07:00:00",
],
)
def test_unusable_timestamps_are_ignored(
self, session_file: Path, monkeypatch: pytest.MonkeyPatch, fired_at: str
) -> None:
"""Neither an unparsable nor a naive timestamp can drive a re-fire."""
monkeypatch.setattr(_alarm_session, "verify_entry_hmac", lambda _e: True)
session_file.write_text(
json.dumps({"state": STATE_RINGING, "fired_at": fired_at})
)
assert should_refire() is False
def test_age_is_reported_for_a_live_session(self) -> None:
"""The age helper is what makes the bound observable."""
start_session("wake-20260827-0700")
later = datetime.now(tz=UTC).astimezone() + timedelta(seconds=90)
age = unfinished_session_age(now=later)
assert age is not None
assert 89 <= age <= 92