wake-alarm/wake_alarm/tests/test_fallback_observe.py
Krzysztof kuhy Rudnicki 8696f2d0bf wake_alarm: sick days stand the PC down (fallback ring, RTC wake, trigger, workday penalty)
A date in the new shared sickdays pool (not a free day) now means, on the PC:
no fallback ring (a ring in progress stops, reason sick_day), no RTC
self-wake for that morning (ours is disarmed), no morning-routine trigger,
and no workday_penalty.json; a penalty issued on a day later marked sick is
retracted (penalty_cancelled, cause sick_day) by the next session refresh or
RTC tick. The audit treats the day as one where nothing is expected.

All pool access goes through wake_alarm/_sick.py: fail closed (unreadable
pool or missing package = not sick), and act modes pull the pool from
Firebase first (RTC --arm on a stick-day target, in-window fallback ticks
and ring polls, the day's first penalty write) because the sickdays timer
pauses through hibernate. Dry runs never pull.

Pins: sickdays-v0.1.0, freedays-v0.4.0, crdt-sync-v0.9.2 (needed by both);
pre-commit 4.7.0, pylint 4.1.3, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 08:45:29 +02:00

142 lines
4.5 KiB
Python

"""Gathering the fallback's facts, and the bookkeeping they trigger."""
from __future__ import annotations
from datetime import datetime
import json
import logging
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from wake_alarm import _fallback_observe
from wake_alarm._events import last_value, remember
from wake_alarm._fallback_latch import Latch, mark_started, mark_stopped
from wake_alarm._fallback_observe import (
SETTLED_SLOT,
observe,
record,
remember_final,
settled_reason,
)
from wake_alarm._sync import Alarm
if TYPE_CHECKING:
from collections.abc import Generator
DAY = "2026-10-01"
NOW = datetime(2026, 10, 1, 7, 10).astimezone()
REASON = "the phone was in a drawer charging and never rang this morning"
def _log(**fields: object) -> str:
return json.dumps({"alarm": {"fields": {k: [v, "h"] for k, v in fields.items()}}})
@pytest.fixture(autouse=True)
def _alarm() -> Generator[None]:
with patch.object(
_fallback_observe, "get_alarm", return_value=Alarm(7, 0, "cache")
):
yield
def test_outside_the_window_never_fetches() -> None:
fetch = MagicMock()
with patch.object(_fallback_observe, "fetch_firebase_logs", fetch):
seen = observe(datetime(2026, 10, 1, 5, 0).astimezone(), sync=True)
fetch.assert_not_called()
assert seen.reachable
assert seen.view is None
def test_a_free_day_never_fetches() -> None:
fetch = MagicMock()
with (
patch.object(_fallback_observe, "fetch_firebase_logs", fetch),
patch.object(_fallback_observe.freedays, "is_free_day", return_value=True),
):
assert observe(NOW, sync=False).facts.free_day
fetch.assert_not_called()
def test_unreachable_firebase_is_no_ping_no_session() -> None:
with patch.object(_fallback_observe, "fetch_firebase_logs", return_value=None):
seen = observe(NOW, sync=False)
assert not seen.reachable
facts = seen.facts
assert not (facts.escaped)
assert not (facts.session_started)
assert not (facts.ring_started)
def test_facts_from_the_phone() -> None:
session = {"date": DAY, "started_at": f"{DAY}T07:05:00", "outcome": "active"}
raw = _log(
current_session=session,
ring_started={"date": DAY, "at": f"{DAY}T07:00:05"},
escapes=[{"id": "e", "date": DAY, "at": "x", "reason": REASON}],
)
with patch.object(
_fallback_observe, "fetch_firebase_logs", return_value=[("p", raw)]
):
seen = observe(NOW, sync=False)
assert seen.facts.session_started
assert seen.facts.ring_started
assert seen.facts.escaped
assert seen.view is not None
def test_yesterdays_session_does_not_count() -> None:
session = {"date": "2026-09-30", "started_at": "x", "outcome": "completed"}
with patch.object(
_fallback_observe,
"fetch_firebase_logs",
return_value=[("p", _log(morning_sessions=[session]))],
):
assert not observe(NOW, sync=False).facts.session_started
def test_settled_from_latch_or_event_state() -> None:
assert settled_reason(DAY, None) is None
latch = mark_stopped(
mark_started(None, day=DAY, now=NOW, reason="phone_silent"),
now=NOW,
stop_reason="cap",
)
assert settled_reason(DAY, latch) == "cap"
assert settled_reason(DAY, Latch(date=DAY, stop_reason="terminated")) is None
remember_final(DAY, "waiting")
assert last_value(SETTLED_SLOT) is None
remember_final(DAY, "escape")
assert settled_reason(DAY, None) == "escape"
assert settled_reason("2026-10-02", None) is None
remember(SETTLED_SLOT, f"{DAY}|")
assert settled_reason(DAY, None) is None
def test_record_logs_unreachable_once_and_resets(
caplog: pytest.LogCaptureFixture,
) -> None:
with (
patch.object(_fallback_observe, "fetch_firebase_logs", return_value=None),
caplog.at_level(logging.INFO),
):
seen = observe(NOW, sync=False)
record(seen)
record(seen)
assert caplog.text.count("firebase_unreachable:") == 1
with patch.object(_fallback_observe, "fetch_firebase_logs", return_value=[]):
record(observe(NOW, sync=False))
assert last_value("firebase_unreachable") is None
def test_record_notes_escapes() -> None:
raw = _log(escapes=[{"id": "e", "date": DAY, "at": "x", "reason": REASON}])
with patch.object(
_fallback_observe, "fetch_firebase_logs", return_value=[("p", raw)]
):
record(observe(NOW, sync=False))
assert last_value("escape_seen") == "e"