mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
Retry session refresh; sign next_step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
This commit is contained in:
parent
672eaf8aba
commit
537a3a79e4
@ -82,8 +82,18 @@ cutoffs, neither imports `crdt_sync`, neither talks to Firebase:
|
||||
**A PC booted mid-morning** has a missing or yesterday's file until the
|
||||
timer's `Persistent=true` catch-up lands, and that needs the network. Only
|
||||
the enforce path, only inside 05:00–11:00, and only when the file is not
|
||||
today's, both consumers retry for ≤ 30 s (5 s steps) and then proceed as
|
||||
today's, both consumers retry for ≤ 180 s (5 s steps) and then proceed as
|
||||
normal with a WARNING. Status paths never wait; tests pin the retry to 0.
|
||||
The refresher itself retries an unreadable backend every 5 s for 150 s
|
||||
(`REFRESH_RETRY_SECONDS`) before exiting 1 with a WARNING: a PC booted at
|
||||
07:48 had no DNS for its first seconds, and the old single try left the next
|
||||
chance at the 07:50 tick (2026-11-07).
|
||||
|
||||
**`next_step`** (signed with the rest): while the session is `in_progress`,
|
||||
the label of the first leg not in the phone's `passed` map, else `null`.
|
||||
Names and labels live in `_session_legs.py` and are pinned to the phone's
|
||||
`SessionLeg` by `wake_alarm/tests/fixtures/session_legs.json`, asserted by a
|
||||
Python and a Dart test.
|
||||
|
||||
## Known limits (v1, deliberate)
|
||||
|
||||
|
||||
24
phone_app/test/services/session_legs_fixture_test.dart
Normal file
24
phone_app/test/services/session_legs_fixture_test.dart
Normal file
@ -0,0 +1,24 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wake_alarm_sync/services/morning_session.dart';
|
||||
|
||||
/// The PC reads leg names (the keys of the published `passed` map) and labels
|
||||
/// (its `next_step`) from the same literal: the Python half asserts it in
|
||||
/// `wake_alarm/tests/test_session_legs.py`. Neither side's own round-trip can
|
||||
/// catch a rename on the other.
|
||||
void main() {
|
||||
test('SessionLeg names and labels equal the shared fixture', () {
|
||||
final raw = File(
|
||||
'../wake_alarm/tests/fixtures/session_legs.json',
|
||||
).readAsStringSync();
|
||||
final legs = (jsonDecode(raw) as Map<String, Object?>)['legs']! as List;
|
||||
expect(
|
||||
<(String, String)>[for (final l in SessionLeg.values) (l.name, l.label)],
|
||||
<(String, String)>[
|
||||
for (final l in legs) (l['name'] as String, l['label'] as String),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -11,8 +11,9 @@ OnCalendar=*-*-* 05..06:00/10:00
|
||||
OnCalendar=*-*-* 07..10:00/2:00
|
||||
OnCalendar=*-*-* 11:00:00
|
||||
AccuracySec=1s
|
||||
# A PC booted mid-morning refreshes at once; the consumers wait up to 30 s
|
||||
# for exactly this catch-up before treating the file as stale.
|
||||
# A PC booted mid-morning refreshes at once, retrying in-process every 5 s
|
||||
# for 150 s (no DNS yet at boot); the consumers wait up to 180 s for exactly
|
||||
# this catch-up before treating the file as stale.
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
|
||||
@ -156,6 +156,13 @@ SESSION_COMPLETED_UNTIL: tuple[int, int] = (11, 0)
|
||||
# PC). A phone switched off at the alarm locks this long after it.
|
||||
SESSION_NONE_GRACE: timedelta = timedelta(minutes=15)
|
||||
|
||||
# A PC booted mid-morning often has no DNS for its first seconds (2026-11-07:
|
||||
# one try at 07:48:43, exit 1, next timer tick at 07:50). ``--refresh`` keeps
|
||||
# trying in-process this long, every REFRESH_POLL_SECONDS, before giving up.
|
||||
# Consumers (gatelock.morning_session) wait up to 180 s, so this stays under.
|
||||
REFRESH_RETRY_SECONDS: int = 150
|
||||
REFRESH_POLL_SECONDS: int = 5
|
||||
|
||||
|
||||
# Workday stick (a Tue/Wed/Thu miss -> tomorrow loses its leniency). The days
|
||||
# themselves are freedays.WORKDAYS, the one set every gate app reads.
|
||||
|
||||
@ -31,10 +31,17 @@ from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
||||
|
||||
from wake_alarm._constants import FALLBACK_ALARM_HOUR, MORNING_SESSION_FILE
|
||||
from wake_alarm._constants import (
|
||||
FALLBACK_ALARM_HOUR,
|
||||
MORNING_SESSION_FILE,
|
||||
REFRESH_POLL_SECONDS,
|
||||
REFRESH_RETRY_SECONDS,
|
||||
)
|
||||
from wake_alarm._enforcement_payload import enforcement_view
|
||||
from wake_alarm._escapes import note_escapes
|
||||
from wake_alarm._logging_setup import configure_logging
|
||||
@ -45,6 +52,9 @@ from wake_alarm._sync_transport import fetch_firebase_logs
|
||||
from wake_alarm._workday_penalty import cancel_for_sick_day as _cancel_for_sick_day
|
||||
from wake_alarm._workday_penalty import maybe_apply as _maybe_apply_workday_penalty
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -81,18 +91,49 @@ def read_entry() -> dict[str, object] | None:
|
||||
return entry
|
||||
|
||||
|
||||
def refresh(now: datetime) -> dict[str, object] | None:
|
||||
def _fetch_with_retry(
|
||||
sleep: Callable[[float], None], clock: Callable[[], float]
|
||||
) -> list[tuple[str, str]] | None:
|
||||
"""``fetch_firebase_logs`` retried every poll interval within the budget.
|
||||
|
||||
A PC booted mid-morning usually has no DNS for its first seconds, and the
|
||||
next timer tick is minutes away while consumers are already waiting on the
|
||||
file. None only once ``REFRESH_RETRY_SECONDS`` have passed.
|
||||
"""
|
||||
started = clock()
|
||||
while True:
|
||||
published = fetch_firebase_logs()
|
||||
if published is not None:
|
||||
return published
|
||||
waited = clock() - started
|
||||
if waited + REFRESH_POLL_SECONDS > REFRESH_RETRY_SECONDS:
|
||||
_logger.warning(
|
||||
"Morning session not refreshed: backend unreadable for %d s "
|
||||
"(retried every %d s)",
|
||||
REFRESH_RETRY_SECONDS,
|
||||
REFRESH_POLL_SECONDS,
|
||||
)
|
||||
return None
|
||||
sleep(REFRESH_POLL_SECONDS)
|
||||
|
||||
|
||||
def refresh(
|
||||
now: datetime,
|
||||
*,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> dict[str, object] | None:
|
||||
"""Fetch, select, and rewrite the file; None when the backend was unreadable.
|
||||
|
||||
An unreadable backend leaves the file alone: whatever it says is still
|
||||
the newest thing known, and a consumer judges freshness by its date. A
|
||||
penalty issued on a day since marked sick is retracted first, from the
|
||||
local pool alone, so it does not wait on Firebase.
|
||||
An unreadable backend is retried for ``REFRESH_RETRY_SECONDS``, then
|
||||
leaves the file alone: whatever it says is still the newest thing known,
|
||||
and a consumer judges freshness by its date. A penalty issued on a day
|
||||
since marked sick is retracted first, once, from the local pool alone, so
|
||||
it does not wait on Firebase. ``sleep`` and ``clock`` are seams for tests.
|
||||
"""
|
||||
_cancel_for_sick_day()
|
||||
published = fetch_firebase_logs()
|
||||
published = _fetch_with_retry(sleep, clock)
|
||||
if published is None:
|
||||
_logger.info("Morning session not refreshed: backend unreadable")
|
||||
return None
|
||||
today = now.date().isoformat()
|
||||
record = newest_session(published)
|
||||
|
||||
30
wake_alarm/_session_legs.py
Normal file
30
wake_alarm/_session_legs.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""The morning session's legs, in order, as the phone names and labels them.
|
||||
|
||||
Mirrors ``SessionLeg`` and ``SessionLegSlot.label`` in
|
||||
``phone_app/lib/services/morning_session.dart``. A leg's *name* is the key of
|
||||
the phone's ``passed`` map; its *label* is what the PC's ``next_step`` shows.
|
||||
``tests/fixtures/session_legs.json`` is asserted by a Python and a Dart test,
|
||||
so a rename on one side fails the other's build.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Collection
|
||||
|
||||
LEGS: tuple[tuple[str, str], ...] = (
|
||||
("bathroomIn", "Tap the bathroom tag"),
|
||||
("showerDone", "Shower, then tap the bathroom tag again"),
|
||||
("dressed", "Get dressed — tap the wardrobe tag"),
|
||||
("atDesk", "Sit down — tap the desk tag"),
|
||||
)
|
||||
|
||||
|
||||
def next_step(passed: Collection[str]) -> str | None:
|
||||
"""Label of the first leg (in order) not in ``passed``; None when all are."""
|
||||
for name, label in LEGS:
|
||||
if name not in passed:
|
||||
return label
|
||||
return None
|
||||
@ -52,6 +52,8 @@ class SessionRecord:
|
||||
re_rings: int
|
||||
device_id: str
|
||||
published_at: float | None
|
||||
# Names (``_session_legs.LEGS``) of the legs the phone has passed.
|
||||
passed_legs: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def finished(self) -> bool:
|
||||
@ -73,6 +75,19 @@ def _float_or_none(value: object) -> float | None:
|
||||
return float(value)
|
||||
|
||||
|
||||
def _passed_legs(value: object) -> tuple[str, ...]:
|
||||
"""Leg names in the phone's ``passed`` map that carry a pass timestamp."""
|
||||
if not isinstance(value, dict):
|
||||
return ()
|
||||
return tuple(
|
||||
name
|
||||
for name, entry in value.items()
|
||||
if isinstance(name, str)
|
||||
and isinstance(entry, dict)
|
||||
and isinstance(entry.get("at"), str)
|
||||
)
|
||||
|
||||
|
||||
def record_from_json(
|
||||
value: object,
|
||||
*,
|
||||
@ -106,6 +121,7 @@ def record_from_json(
|
||||
re_rings=_int_or_none(value.get("re_rings")) or 0,
|
||||
device_id=device_id,
|
||||
published_at=published_at,
|
||||
passed_legs=_passed_legs(value.get("passed")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ from wake_alarm._constants import (
|
||||
SESSION_PROVISIONAL_CAP,
|
||||
SESSION_PROVISIONAL_GRACE,
|
||||
)
|
||||
from wake_alarm._session_legs import next_step
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wake_alarm._session_payload import SessionRecord
|
||||
@ -113,6 +114,7 @@ def to_entry(
|
||||
"device_id": "",
|
||||
"published_at": None,
|
||||
"refreshed_at": refreshed_at,
|
||||
"next_step": None,
|
||||
}
|
||||
if alarm is not None:
|
||||
entry["exempt_until"] = (_at(today, alarm) + SESSION_NONE_GRACE).isoformat()
|
||||
@ -128,6 +130,10 @@ def to_entry(
|
||||
"device_id": record.device_id,
|
||||
"published_at": record.published_at,
|
||||
"refreshed_at": refreshed_at,
|
||||
# Signed with the rest: the HMAC covers every key of the entry.
|
||||
"next_step": (
|
||||
next_step(record.passed_legs) if record.outcome == "in_progress" else None
|
||||
),
|
||||
}
|
||||
until = exempt_until(record)
|
||||
if until is not None:
|
||||
|
||||
@ -49,6 +49,33 @@ def _no_process(cmd: list[str]) -> object:
|
||||
raise AssertionError(msg)
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""A monotonic clock that only moves when ``sleep`` is called.
|
||||
|
||||
Pass ``clock.sleep`` and ``clock.now`` to ``_session.refresh`` so a dead
|
||||
backend burns its 150 s retry budget instantly instead of for real.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.elapsed = 0.0
|
||||
self.sleeps: list[float] = []
|
||||
|
||||
def now(self) -> float:
|
||||
"""Seconds elapsed on the fake clock."""
|
||||
return self.elapsed
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
"""Advance the clock without waiting."""
|
||||
self.sleeps.append(seconds)
|
||||
self.elapsed += seconds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_clock() -> FakeClock:
|
||||
"""A fresh :class:`FakeClock`."""
|
||||
return FakeClock()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hermetic(tmp_path: Path) -> Generator[None]:
|
||||
"""Keep signing keys, state writes and processes inside ``tmp_path``."""
|
||||
|
||||
8
wake_alarm/tests/fixtures/session_legs.json
vendored
Normal file
8
wake_alarm/tests/fixtures/session_legs.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"legs": [
|
||||
{"name": "bathroomIn", "label": "Tap the bathroom tag"},
|
||||
{"name": "showerDone", "label": "Shower, then tap the bathroom tag again"},
|
||||
{"name": "dressed", "label": "Get dressed — tap the wardrobe tag"},
|
||||
{"name": "atDesk", "label": "Sit down — tap the desk tag"}
|
||||
]
|
||||
}
|
||||
@ -172,12 +172,6 @@ class TestRefresh:
|
||||
assert entry is not None
|
||||
assert entry["exempt_until"] == _local("2026-09-19T06:45:00").isoformat()
|
||||
|
||||
def test_unreadable_backend_leaves_the_file_alone(self) -> None:
|
||||
write_entry(to_entry(_record(), today="2026-09-19", refreshed_at=5.0))
|
||||
with patch("wake_alarm._session.fetch_firebase_logs", return_value=None):
|
||||
assert refresh(NOW) is None
|
||||
assert read_entry() is not None
|
||||
|
||||
def test_write_failure_is_none(self) -> None:
|
||||
with (
|
||||
patch("wake_alarm._session.fetch_firebase_logs", return_value=[]),
|
||||
|
||||
30
wake_alarm/tests/test_session_legs.py
Normal file
30
wake_alarm/tests/test_session_legs.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""The leg names and labels the PC reads must equal the phone's enum.
|
||||
|
||||
``fixtures/session_legs.json`` is one literal both sides assert: this test
|
||||
against ``_session_legs.LEGS`` and ``phone_app/test/services/
|
||||
session_legs_fixture_test.dart`` against ``SessionLeg``. Each side's own
|
||||
round-trip cannot catch a naming drift with its sibling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from wake_alarm._session_legs import LEGS, next_step
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "session_legs.json"
|
||||
|
||||
|
||||
def test_legs_equal_the_shared_fixture() -> None:
|
||||
legs = json.loads(FIXTURE.read_text(encoding="utf-8"))["legs"]
|
||||
assert [(leg["name"], leg["label"]) for leg in legs] == list(LEGS)
|
||||
|
||||
|
||||
def test_next_step_is_the_first_leg_not_passed() -> None:
|
||||
assert next_step(()) == "Tap the bathroom tag"
|
||||
assert next_step(("bathroomIn",)) == "Shower, then tap the bathroom tag again"
|
||||
# Order of the legs decides, not the order they were passed in.
|
||||
assert next_step(("showerDone",)) == "Tap the bathroom tag"
|
||||
assert next_step(("atDesk", "bathroomIn", "showerDone", "dressed")) is None
|
||||
assert next_step(("unknownLeg",)) == "Tap the bathroom tag"
|
||||
@ -64,6 +64,26 @@ class TestRecordFromJson:
|
||||
assert record.finished_at is None
|
||||
assert not record.finished
|
||||
|
||||
def test_passed_legs_are_the_keys_with_a_timestamp(self) -> None:
|
||||
passed = {
|
||||
"bathroomIn": {"at": "2026-09-19T08:05:00.000", "how": "tag"},
|
||||
"showerDone": "garbage",
|
||||
"dressed": {"how": "tag"},
|
||||
}
|
||||
record = record_from_json(
|
||||
_session("2026-09-19", "active", passed=passed),
|
||||
device_id="p",
|
||||
published_at=None,
|
||||
)
|
||||
assert record is not None
|
||||
assert record.passed_legs == ("bathroomIn",)
|
||||
for bad in (None, [], "x"):
|
||||
record = record_from_json(
|
||||
_session(passed=bad), device_id="p", published_at=None
|
||||
)
|
||||
assert record is not None
|
||||
assert record.passed_legs == ()
|
||||
|
||||
def test_rejects_every_malformed_shape(self) -> None:
|
||||
bad = [
|
||||
"text",
|
||||
|
||||
118
wake_alarm/tests/test_session_retry.py
Normal file
118
wake_alarm/tests/test_session_retry.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""``refresh`` retries an unreadable backend; ``next_step`` is signed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from wake_alarm import _session
|
||||
from wake_alarm._constants import REFRESH_POLL_SECONDS, REFRESH_RETRY_SECONDS
|
||||
from wake_alarm._session import read_entry, refresh, to_entry, write_entry
|
||||
from wake_alarm._session_payload import SessionRecord
|
||||
from wake_alarm._sync import Alarm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wake_alarm.tests.conftest import FakeClock
|
||||
|
||||
NOW = datetime(2026, 9, 19, 8, 30).astimezone()
|
||||
TODAY = "2026-09-19"
|
||||
|
||||
|
||||
def _live(passed: tuple[str, ...] = ("bathroomIn",)) -> SessionRecord:
|
||||
return SessionRecord(
|
||||
date=TODAY,
|
||||
outcome="in_progress",
|
||||
started_at="2026-09-19T08:01:00.000",
|
||||
finished_at=None,
|
||||
energy=3,
|
||||
weight_kg=73.8,
|
||||
re_rings=0,
|
||||
device_id="p",
|
||||
published_at=1.0,
|
||||
passed_legs=passed,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _alarm() -> object:
|
||||
with patch("wake_alarm._session.get_alarm", return_value=Alarm(8, 0, "cache")):
|
||||
yield
|
||||
|
||||
|
||||
class TestRefreshRetry:
|
||||
"""2026-11-07: booted without DNS; one try, then a 2-minute gap."""
|
||||
|
||||
def test_unreadable_backend_leaves_the_file_alone(
|
||||
self, fake_clock: FakeClock
|
||||
) -> None:
|
||||
write_entry(to_entry(_live(), today=TODAY, refreshed_at=5.0))
|
||||
with patch("wake_alarm._session.fetch_firebase_logs", return_value=None):
|
||||
assert refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) is None
|
||||
assert read_entry() is not None
|
||||
|
||||
def test_a_backend_that_recovers_is_retried_into_todays_entry(
|
||||
self, fake_clock: FakeClock
|
||||
) -> None:
|
||||
with patch(
|
||||
"wake_alarm._session.fetch_firebase_logs", side_effect=[None, None, []]
|
||||
) as fetch:
|
||||
entry = refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now)
|
||||
assert entry is not None
|
||||
assert (entry["date"], entry["outcome"]) == (TODAY, "none")
|
||||
assert read_entry() == entry
|
||||
assert fetch.call_count == 3
|
||||
assert fake_clock.sleeps == [REFRESH_POLL_SECONDS, REFRESH_POLL_SECONDS]
|
||||
|
||||
def test_a_dead_backend_gives_up_after_the_budget_and_says_so(
|
||||
self, caplog: pytest.LogCaptureFixture, fake_clock: FakeClock
|
||||
) -> None:
|
||||
with (
|
||||
patch("wake_alarm._session.fetch_firebase_logs", return_value=None),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
assert refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) is None
|
||||
assert fake_clock.sleeps == [REFRESH_POLL_SECONDS] * (
|
||||
REFRESH_RETRY_SECONDS // REFRESH_POLL_SECONDS
|
||||
)
|
||||
warnings = [r.getMessage() for r in caplog.records if r.levelno >= 30]
|
||||
assert any(f"{REFRESH_RETRY_SECONDS} s" in w for w in warnings)
|
||||
|
||||
def test_sick_day_cancel_runs_once_before_the_loop(
|
||||
self, fake_clock: FakeClock
|
||||
) -> None:
|
||||
with (
|
||||
patch("wake_alarm._session.fetch_firebase_logs", return_value=None),
|
||||
patch("wake_alarm._session._cancel_for_sick_day") as cancel,
|
||||
):
|
||||
refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now)
|
||||
cancel.assert_called_once()
|
||||
|
||||
|
||||
class TestNextStep:
|
||||
"""The entry names the next leg and signs it."""
|
||||
|
||||
def test_in_progress_entry_names_the_next_leg(self) -> None:
|
||||
entry = to_entry(_live(), today=TODAY, refreshed_at=5.0)
|
||||
assert entry["next_step"] == "Shower, then tap the bathroom tag again"
|
||||
|
||||
def test_next_step_is_null_unless_the_morning_is_live(self) -> None:
|
||||
done = replace(_live(), outcome="completed", finished_at="2026-09-19T08:40")
|
||||
assert to_entry(done, today=TODAY, refreshed_at=5.0)["next_step"] is None
|
||||
failed = replace(_live(), outcome="failed")
|
||||
assert to_entry(failed, today=TODAY, refreshed_at=5.0)["next_step"] is None
|
||||
assert to_entry(None, today=TODAY, refreshed_at=5.0)["next_step"] is None
|
||||
|
||||
def test_tampering_next_step_breaks_verification(self) -> None:
|
||||
write_entry(to_entry(_live(), today=TODAY, refreshed_at=5.0))
|
||||
assert read_entry() is not None
|
||||
path = _session.MORNING_SESSION_FILE
|
||||
tampered = json.loads(path.read_text())
|
||||
tampered["next_step"] = None
|
||||
path.write_text(json.dumps(tampered))
|
||||
assert read_entry() is None
|
||||
@ -7,6 +7,7 @@ from datetime import date, datetime
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@ -32,6 +33,9 @@ from wake_alarm._workday_penalty import (
|
||||
write_penalty,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wake_alarm.tests.conftest import FakeClock
|
||||
|
||||
TUESDAY = date(2026, 9, 22)
|
||||
AFTER_GRACE = datetime(2026, 9, 22, 9, 0).astimezone()
|
||||
MISS: dict[str, object] = {
|
||||
@ -131,10 +135,15 @@ class TestRetraction:
|
||||
)
|
||||
assert not cancel_for_sick_day()
|
||||
|
||||
def test_refresh_retracts_even_offline(self) -> None:
|
||||
def test_refresh_retracts_even_offline(self, fake_clock: FakeClock) -> None:
|
||||
mark_sick(TUESDAY)
|
||||
with patch.object(_session, "fetch_firebase_logs", return_value=None):
|
||||
assert _session.refresh(AFTER_GRACE) is None
|
||||
assert (
|
||||
_session.refresh(
|
||||
AFTER_GRACE, sleep=fake_clock.sleep, clock=fake_clock.now
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert issued_date() is None
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user