mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
`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
135 lines
5.0 KiB
Python
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
|