diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b6b9d0..000a9c3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -106,8 +106,9 @@ repos: # the manifest becomes an unresolvable hook environment rather # than merely an old one. - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock - - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync - - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays + - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync + - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays + - sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays # Test suites intentionally use patterns (protected-access, magic-value # comparisons) that don't belong in the source-code 10/10 gate. exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py) diff --git a/AGENTS.md b/AGENTS.md index 09c93db..382321d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,8 @@ clock, and everything that is a hard crash or a silent no-ring: **Free days.** On a day in the shared pool (`~/src/utils/freedays`) the phone does not ring at all: `AlarmReceiver` checks a native cache Dart rewrites after every mark/release/sync. Picker, cache and its fail-closed contract: -**`DOCS-free-days.md`**. +**`DOCS-free-days.md`**. **Sick days** (`~/src/utils/sickdays`, never a free +day) stand the PC down too, via `_sick.py`: **`DOCS-wake-enforcement.md`**. ## Scheduling — a per-minute timer diff --git a/DOCS-wake-enforcement.md b/DOCS-wake-enforcement.md index ee19151..b33b5df 100644 --- a/DOCS-wake-enforcement.md +++ b/DOCS-wake-enforcement.md @@ -21,7 +21,7 @@ lever is something that bites *today*. Two holes made skipping cheap: ### PC fallback alarm (every ringing day) Let `A` be today's alarm (`_sync.get_alarm()`, clamped ≤ 08:00 like the -trigger). Not on a free day. Per-minute tick, `A-5min .. A+45min`: +trigger). Not on a free day, not on a sick day (below). Per-minute tick, `A-5min .. A+45min`: | condition (first match wins) | action | |---|---| @@ -76,6 +76,50 @@ During a workday lockdown focus-owner applies its **lockdown allowlist** (calls, SMS, Signal, maps, transit/ticket, banking + authenticator, wake-alarm, todo) — narrower than the night list. +### Sick days (PC side, 2026-10-16) + +A sick day is a date in the shared `sickdays` pool (`~/src/utils/sickdays`): +uncapped, no reason, today or later, and **not a free day** -- its own pool, +its own reason strings (`sick_day` / `sick day`). On a sick day `D` (the +alarm's day, so the RTC asks about *tomorrow* the evening before): + +| piece | on a sick day | +|---|---| +| fallback ring | never starts; a ring in progress stops (`stop_reason` `sick_day`) | +| RTC self-wake | not armed for `D`; an alarm we armed for `D` is disarmed (`rtc_skip` / `rtc_disarmed`, reason `sick day`) | +| trigger | `morning-routine.service` is not started (`sick day`), like a free day | +| workday penalty | never written for `D`; one *issued* on `D` before `D` was marked is deleted (`penalty_cancelled`, `cause: sick_day`) by the next `_session --refresh` or `_rtc --arm` | +| `morning_session.json` | unchanged: it only *grants* exemptions, and every locker stands down on a sick day by its own check | +| audit | RTC / up-by-A / ring / lockdown checks are n/a; a penalty for `D` on disk or a `lockdown_sent` for a miss on `D` is flagged | + +All of it lives behind `wake_alarm/_sick.py`. **Fail closed:** an +unreadable pool, or `sickdays` not importable under `/usr/bin/python`, is +"not sick" -- enforcement runs. + +**Sync.** The decisions read the local pool, and the act modes pull it from +Firebase first (`sickdays.sync_quietly()`), because the sickdays sync timer +alone cannot be trusted at the moment that matters: it is a monotonic +15-minute timer, paused during hibernate and with no `Persistent` catch-up, +so right after the RTC resume the pool can be an interval stale. Pulled: +`_rtc --arm` when the target is a stick day that is not free; the fallback +tick whenever it already goes to the network (`A-5 .. A+45`, and every poll +of a ring in progress, so a day marked sick mid-ring stops it); the day's +first penalty write. The trigger reads only the local file -- the fallback +has pulled from `A-5`. `--dry-run` / `--show` never pull (a pull rewrites +the pool file). + +**Other readers of `workday_penalty.json`** (screen-locker, leetcode-guard) +must treat the file as void when **either** `issued_date` **or** +`penalty_date` is a sick day (`sickdays.is_sick_day(date.fromisoformat(...))`). +`issued_date` covers a miss at 08:15 marked sick at 14:00 -- the deletion +runs on the next RTC tick (<= 15 min) or the next morning's refresh, and a +reader must not depend on that; `penalty_date` is a sick day on which every +locker stands down anyway. + +**Phone, assumed name:** the phone's `alarm_fired` on a sick day carries +`data.reason = "sick_day"` (by analogy with `free_day`); the audit treats +it as not the morning's ring. + ### Escape hatch (until 2026-10-15 inclusive) Home screen button "That was a bug". Needs a typed reason ≥ 40 characters. @@ -113,13 +157,14 @@ shown comes from Dart constants mirrored against the Python ones by - `events.jsonl` — every PC event (`_log_sink`), `event` key names below. - `fallback_ring.json` — today's latch: `{"date", "started_at", "stopped_at", "reason", "stop_reason"}`. -- `workday_penalty.json` — unchanged shape; not written for an escaped day, - and removed when an escape arrives for the day it was issued. +- `workday_penalty.json` — unchanged shape; not written for an escaped day + or a sick day, and removed when an escape arrives for the day it was + issued or that day is marked sick. ### Event names Phone (`phone_events.kind`): `alarm_fired` (data: `reason` = -`daily`|`rering`|`free_day` — only `daily` is the morning's ring), +`daily`|`rering`|`free_day`|`sick_day` — only `daily` is the morning's ring), `ring_screen_opened`, `ring_started_published` (data: `date`, `source` = `alarm`|`ring_screen`), `dismissed`, `stick_check` (data: `trigger` = `exact`|`boot`|`watchdog`, `date` judged, `verdict`), `lockdown_sent` (data: @@ -135,7 +180,7 @@ system's next alarm. Boot/watchdog catch up on up to 5 unchecked past days. PC (`events.jsonl` `event`): `fallback_tick` (only on decision change), `fallback_ring_start`, `fallback_ring_stop`, `ring_backend`, `rtc_armed`, `rtc_skip`, `firebase_unreachable`, `escape_seen`, `penalty_written`, -`penalty_cancelled`, `audit_written`. +`penalty_cancelled` (field `cause`: `escape` | `sick_day`), `audit_written`. ## Verification status (2026-10-01) diff --git a/pyproject.toml b/pyproject.toml index 54d6e2f..701d2e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,11 @@ dependencies = [ # Firebase transport for the alarm read. _sync.py imports it lazily and # falls back to its stdlib GitHub path if the import fails, so a broken # install degrades the read instead of killing the per-minute trigger. - "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync", - "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays", + "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync", + "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays", + # Sick days stand the PC's enforcement down (_sick.py). Imported behind + # a guard: missing means "not sick", never a crashed tick. + "sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays", ] # Package discovery must be explicit: phone_app/ is a Flutter app, not a Python diff --git a/requirements.txt b/requirements.txt index 85736e8..7665d35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,15 +5,16 @@ bandit==1.9.4 codespell==2.4.3 coverage==7.16.2 -crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync -freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays +crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync +freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock mypy==2.4.0 -pre-commit==4.6.2 -pylint==4.1.2 +pre-commit==4.7.0 +pylint==4.1.3 pytest==9.1.1 pytest-cov==7.1.0 pytest-randomly==5.0.0 pytest-sugar==1.1.1 pytest-xdist==3.8.0 -ruff==0.16.10 +ruff==0.17.1 +sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays diff --git a/wake_alarm/_audit.py b/wake_alarm/_audit.py index 5c0c206..1d8e2c9 100644 --- a/wake_alarm/_audit.py +++ b/wake_alarm/_audit.py @@ -36,6 +36,7 @@ from wake_alarm._enforcement_records import parse_instant from wake_alarm._events import emit from wake_alarm._fallback_policy import alarm_instant from wake_alarm._logging_setup import configure_logging +from wake_alarm._sick import is_sick from wake_alarm._sync import get_alarm from wake_alarm._sync_transport import fetch_firebase_logs from wake_alarm._workday_penalty import issued_date @@ -64,6 +65,7 @@ def gather(day: date) -> tuple[Evidence, bool]: day=day, stick_day=freedays.is_workday(day), free_day=freedays.is_free_day(day), + sick_day=is_sick(day), alarm_at=alarm_at, alarm_source=source, pc_side=PcSide( @@ -117,7 +119,15 @@ def _timeline(ev: Evidence) -> list[str]: def render(ev: Evidence, checks: list[Check], *, reachable: bool) -> str: """The whole report.""" - kind = "free day" if ev.free_day else "stick day" if ev.stick_day else "carrot day" + kind = ( + "sick day" + if ev.sick_day + else "free day" + if ev.free_day + else "stick day" + if ev.stick_day + else "carrot day" + ) lines = [f"# Wake audit {ev.day} ({ev.day:%a}, {kind})", "", "## Flags", ""] flagged = flags(ev, checks) if not reachable: diff --git a/wake_alarm/_audit_checks.py b/wake_alarm/_audit_checks.py index f869290..72df0e6 100644 --- a/wake_alarm/_audit_checks.py +++ b/wake_alarm/_audit_checks.py @@ -29,11 +29,12 @@ if TYPE_CHECKING: from wake_alarm._audit_sources import PcEvent -_FINAL_STOPS = frozenset({"escape", "session_started", "cap", "free_day"}) +_FINAL_STOPS = frozenset({"escape", "session_started", "cap", "free_day", "sick_day"}) _RING_KINDS = frozenset({"ring_screen_opened", "ring_started_published"}) # alarm_fired.data.reason: only the daily ring is the morning's ring -- a -# free_day firing rings nothing, and a rering presupposes an earlier ring. -_NOT_THE_MORNING_RING = frozenset({"free_day", "rering"}) +# free_day / sick_day firing rings nothing, and a rering presupposes an +# earlier ring. +_NOT_THE_MORNING_RING = frozenset({"free_day", "sick_day", "rering"}) def alarm_from_events(pc: list[PcEvent], day: date) -> datetime | None: @@ -58,6 +59,8 @@ def check_rtc(ev: Evidence) -> Check: """On a stick day the RTC was armed the night before.""" if not ev.stick_day or ev.free_day: return na("RTC armed the night before", "not a stick day") + if ev.sick_day: + return na("RTC armed the night before", "sick day") armed = [ e for e in ev.pc @@ -75,6 +78,8 @@ def check_up_by_alarm(ev: Evidence) -> Check: """On a stick day the PC was up by ``A``.""" if not ev.stick_day or ev.free_day or ev.alarm_at is None: return na("PC up by A", "not a stick day or no alarm") + if ev.sick_day: + return na("PC up by A", "sick day") alarm = ev.alarm_at lead = alarm - RTC_WAKE_LEAD - timedelta(minutes=5) ups = [u for u in ev.ups if u.at.date() == ev.day and u.at <= alarm] @@ -90,6 +95,8 @@ def check_ring(ev: Evidence) -> Check: """Something rang: the phone's ring screen, or the PC fallback.""" if ev.free_day: return na("something rang", "free day") + if ev.sick_day: + return na("something rang", "sick day") fallback = pc_on_day(ev, "fallback_ring_start") phone = ev.ring_started or any( e.kind in _RING_KINDS diff --git a/wake_alarm/_audit_evidence.py b/wake_alarm/_audit_evidence.py index 5b2f024..c114aa6 100644 --- a/wake_alarm/_audit_evidence.py +++ b/wake_alarm/_audit_evidence.py @@ -51,6 +51,7 @@ class Evidence: alarm_source: str pc_side: PcSide = field(default_factory=PcSide) phone_side: PhoneSide = field(default_factory=PhoneSide) + sick_day: bool = False @property def pc(self) -> list[PcEvent]: diff --git a/wake_alarm/_audit_stick.py b/wake_alarm/_audit_stick.py index dae3ca2..97cc90c 100644 --- a/wake_alarm/_audit_stick.py +++ b/wake_alarm/_audit_stick.py @@ -74,10 +74,11 @@ def check_penalty(ev: Evidence) -> Check: """The PC penalty agrees with the outcome and any escape.""" name = "penalty consistent" escaped = _escaped(ev) - if escaped and ev.penalty_issued == ev.day.isoformat(): - return bad(name, "escaped, but the penalty is on disk") - if escaped: - return ok(name, "escaped: no penalty") + if escaped or ev.sick_day: + why = "escaped" if escaped else "sick day" + if ev.penalty_issued == ev.day.isoformat(): + return bad(name, f"{why}, but the penalty is on disk") + return ok(name, f"{why}: no penalty") if not ev.stick_day or ev.free_day: return na(name, "not a stick day") written = pc_on_day(ev, "penalty_written") @@ -119,6 +120,14 @@ def _lockdown_for_miss(ev: Evidence, judged: date) -> Check: return ok(name, f"until {until}") +def _sick_lockdown(ev: Evidence, judged: date | None) -> Check: + """A sick day expects no lockdown; one sent for its miss is flagged.""" + name = "lockdown consistent" + if judged is not None and _sent_on(ev, judged): + return bad(name, "sick day, but lockdown_sent") + return na(name, "sick day") + + def check_lockdown(ev: Evidence) -> Check: """``lockdown_sent`` agrees with the verdict, and escapes lift it.""" name = "lockdown consistent" @@ -128,6 +137,8 @@ def check_lockdown(ev: Evidence) -> Check: if "lift_sent" not in phone_kinds(ev): return bad(name, "escaped, but no lift_sent") return ok(name, "lifted after the escape") + if ev.sick_day: + return _sick_lockdown(ev, judged) if judged is not None: return _lockdown_for_miss(ev, judged) if sent_today and not _escaped(ev): diff --git a/wake_alarm/_fallback.py b/wake_alarm/_fallback.py index 343b4ee..55487d5 100644 --- a/wake_alarm/_fallback.py +++ b/wake_alarm/_fallback.py @@ -1,7 +1,7 @@ """The PC fallback alarm: one per-minute tick, 04:00-09:59. ``wake-alarm-fallback.timer`` runs ``--tick`` every minute. Outside -``A - 5 min .. A + 45 min`` (or on a free day) a tick decides from local +``A - 5 min .. A + 45 min`` (or on a free or sick day) a tick decides from local state alone and never touches the network. Inside, it reads every device's ``alarm.json`` once, decides (``_fallback_policy.decide``) and, when the decision is to ring, rings *in this process* until a stop condition holds @@ -44,6 +44,7 @@ from wake_alarm._fallback_policy import ( from wake_alarm._fallback_ring import ring from wake_alarm._logging_setup import configure_logging from wake_alarm._ring_wav import SYSTEM_WAV_DIR, USER_WAV_DIR, ensure_pair +from wake_alarm._sick import is_sick from wake_alarm._sync import get_alarm if TYPE_CHECKING: @@ -71,7 +72,7 @@ def _close(latch: Latch, reason: str) -> None: def _poll(day: str) -> str | None: """Re-observe while ringing; the reason to stop, or None.""" - seen = observe(_now()) + seen = observe(_now(), sync=True) record(seen) reason = stop_reason(replace(seen.facts, latch=read_latch(day))) if reason is not None: @@ -105,7 +106,7 @@ def ring_now(facts: Facts, reason: str) -> str: def tick(now: datetime) -> Decision: """One timer tick: observe, log a changed decision, ring if due.""" - seen = observe(now) + seen = observe(now, sync=True) record(seen) facts, day = seen.facts, now.date().isoformat() decision = decide(facts) @@ -134,6 +135,7 @@ def describe(seen: Observation, decision: Decision) -> str: return ( f"{facts.now:%Y-%m-%d %H:%M} — {verdict}: {decision.reason}\n" f" alarm A={facts.alarm_at:%H:%M} free_day={facts.free_day} " + f"sick_day={facts.sick_day} " f"firebase={'ok' if seen.reachable else 'UNREACHABLE'}\n" f" escaped={facts.escaped} session_started={facts.session_started} " f"ring_started={facts.ring_started} settled={facts.settled}\n" @@ -147,7 +149,8 @@ def show(now: datetime) -> str: alarm_at = alarm_instant(get_alarm(), now.date()) return ( f"today {day}: A={alarm_at:%H:%M} " - f"free_day={freedays.is_free_day(now.date())}\n" + f"free_day={freedays.is_free_day(now.date())} " + f"sick_day={is_sick(now.date())}\n" f" latch={read_latch(day)}\n" f" last fallback_tick={last_value('fallback_tick')}\n" f" settled={settled_reason(day, read_latch(day))}\n" @@ -166,7 +169,7 @@ def main(argv: list[str] | None = None) -> int: if args.show: sys.stdout.write(show(now)) elif args.dry_run: - seen = observe(now) + seen = observe(now, sync=False) sys.stdout.write(describe(seen, decide(seen.facts))) else: configure_logging(component="fallback") diff --git a/wake_alarm/_fallback_observe.py b/wake_alarm/_fallback_observe.py index d8bf9ba..2bab023 100644 --- a/wake_alarm/_fallback_observe.py +++ b/wake_alarm/_fallback_observe.py @@ -1,8 +1,8 @@ """One look at the world for the fallback alarm, and its bookkeeping. Split from ``_fallback`` (tick, ring lifecycle, CLI): this is "what is true -right now" -- today's alarm, the latch, the free-day pool and, only when the -decision needs it, every device's ``alarm.json``. +right now" -- today's alarm, the latch, the free-day and sick-day pools +and, only when the decision needs it, every device's ``alarm.json``. """ from __future__ import annotations @@ -23,6 +23,7 @@ from wake_alarm._fallback_policy import ( needs_facts, ) from wake_alarm._session_payload import newest_session +from wake_alarm._sick import is_sick, is_sick_synced from wake_alarm._sync import get_alarm from wake_alarm._sync_transport import fetch_firebase_logs @@ -49,18 +50,37 @@ def settled_reason(day: str, latch: Latch | None) -> str | None: return remembered[2] if remembered[0] == day and remembered[2] else None -def observe(now: datetime) -> Observation: - """Gather the facts; the network only when the decision needs it.""" +def _sick_today(now: datetime, *, online: bool, sync: bool) -> bool: + """Whether today is a sick day; pulls the pool first when acting online. + + Synced only on the ticks that already go to the network (the window, or + a ring in progress), so a day marked sick on the phone mid-ring stops + the ring at the next poll, and an off-window tick stays local. + """ + if online and sync: + return is_sick_synced(now.date()) + return is_sick(now.date()) + + +def observe(now: datetime, *, sync: bool) -> Observation: + """Gather the facts; the network only when the decision needs it. + + ``sync`` is False for ``--dry-run``: pulling the sick-day pool rewrites + its local file, and a dry run writes nothing. + """ day = now.date().isoformat() latch = read_latch(day) + alarm_at = alarm_instant(get_alarm(), now.date()) + online = needs_facts(now, alarm_at, latch) facts = Facts( now=now, - alarm_at=alarm_instant(get_alarm(), now.date()), + alarm_at=alarm_at, free_day=freedays.is_free_day(now.date()), + sick_day=_sick_today(now, online=online, sync=sync), latch=latch, settled=settled_reason(day, latch), ) - if facts.free_day or not needs_facts(now, facts.alarm_at, latch): + if facts.free_day or facts.sick_day or not online: return Observation(facts) published = fetch_firebase_logs() if published is None: diff --git a/wake_alarm/_fallback_policy.py b/wake_alarm/_fallback_policy.py index 13ff530..c98a45d 100644 --- a/wake_alarm/_fallback_policy.py +++ b/wake_alarm/_fallback_policy.py @@ -1,7 +1,7 @@ """The PC fallback alarm's rule, as one pure function. ``DOCS-wake-enforcement.md``'s table, first match wins, inside -``A - 5 min .. A + 45 min`` on a day that is not free: +``A - 5 min .. A + 45 min`` on a day that is neither free nor sick: ========================================================= ================== escape recorded for today, or a session started today do not ring / stop @@ -45,6 +45,7 @@ FALLBACK_POLL_SECONDS: float = 20.0 # Reasons, as they appear in events.jsonl and fallback_ring.json. FREE_DAY = "free_day" +SICK_DAY = "sick_day" OUTSIDE_WINDOW = "outside_window" ESCAPE = "escape" SESSION_STARTED = "session_started" @@ -69,6 +70,7 @@ class Facts: now: datetime alarm_at: datetime free_day: bool + sick_day: bool = False escaped: bool = False session_started: bool = False ring_started: bool = False @@ -121,15 +123,26 @@ def is_capped(facts: Facts) -> bool: return started is not None and facts.now >= started + FALLBACK_RING_CAP +def day_off(facts: Facts) -> str | None: + """``free_day`` / ``sick_day`` when today rings nothing at all.""" + if facts.free_day: + return FREE_DAY + if facts.sick_day: + return SICK_DAY + return None + + def stop_reason(facts: Facts) -> str | None: """Why a ring in progress must stop now, or None to keep ringing. - Only the contract's three stop conditions (plus a day that turned free): + Only the contract's three stop conditions (plus a day that turned free + or sick): a phone that comes back on mid-ring does not silence the PC -- getting up does. """ - if facts.free_day: - return FREE_DAY + off = day_off(facts) + if off is not None: + return off if facts.settled in FINAL_REASONS: return facts.settled if facts.escaped: @@ -143,8 +156,9 @@ def stop_reason(facts: Facts) -> str | None: def decide(facts: Facts) -> Decision: """The table above; first match wins.""" - if facts.free_day: - return Decision(ring=False, reason=FREE_DAY) + off = day_off(facts) + if off is not None: + return Decision(ring=False, reason=off) if not needs_facts(facts.now, facts.alarm_at, facts.latch): return Decision(ring=False, reason=OUTSIDE_WINDOW) stop = stop_reason(facts) diff --git a/wake_alarm/_rtc.py b/wake_alarm/_rtc.py index 3ea98ff..7bd1e59 100644 --- a/wake_alarm/_rtc.py +++ b/wake_alarm/_rtc.py @@ -6,18 +6,26 @@ resume, so a PC hibernated at 16:00 on Monday is armed for Tuesday the moment it next runs at all. The target is the first morning whose wake instant (``A - RTC_WAKE_LEAD``, ``A`` clamped to 08:00) is still ahead -- so a 02:00 tick on a Tuesday targets Tuesday, not Wednesday. Only a stick day -(``freedays.is_workday``: Tue/Wed/Thu) that is not a free day is armed, via +(``freedays.is_workday``: Tue/Wed/Thu) that is neither a free day nor a sick +day (``_sick``) is armed, via ``sudo -n /usr/sbin/rtcwake -m no -t ``. The alarm lives in CMOS, so it survives however the PC goes down. When the next morning is not one to wake for (a carrot day, or a stick day -that became free), an alarm *we* armed is cleared (``rtc_disarmed``); one +that became free or sick), an alarm *we* armed is cleared (``rtc_disarmed``); one someone else set is left alone (``_rtc_hw``). A changed alarm time needs no disarm: arming overwrites the single RTC alarm. ``rtc_armed`` / ``rtc_skip`` are logged only when they change; the audit matches ``rtc_armed`` on its ``target_date``. +``--arm`` pulls the sick-day pool before judging a stick-day target (a day +marked sick on the phone at 23:00 must disarm before the PC goes down); +``--dry-run`` / ``--show`` read the local pool and write nothing. ``--arm`` +also retracts a workday penalty issued on a day since marked sick +(``_workday_penalty.cancel_for_sick_day``): this is the one wake_alarm job +that runs all day, and a day can be marked sick until midnight. + python -m wake_alarm._rtc --arm # what the timer runs python -m wake_alarm._rtc --dry-run # the plan, nothing armed python -m wake_alarm._rtc --show # the plan + the RTC's current alarm @@ -28,6 +36,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta import sys +from typing import TYPE_CHECKING import freedays @@ -38,7 +47,12 @@ from wake_alarm._fallback_policy import alarm_instant from wake_alarm._logging_setup import configure_logging from wake_alarm._pc_wake_mode import read_mode from wake_alarm._rtc_hw import arm, current_alarm, disarm_ours, read_record +from wake_alarm._sick import is_sick, is_sick_synced from wake_alarm._sync import Alarm, get_alarm +from wake_alarm._workday_penalty import cancel_for_sick_day + +if TYPE_CHECKING: + from collections.abc import Callable @dataclass(frozen=True) @@ -61,8 +75,14 @@ def _now() -> datetime: return datetime.now(tz=UTC).astimezone() -def plan(now: datetime, alarm: Alarm) -> Plan: - """Which morning is next, and whether it is one to wake the PC for.""" +def plan( + now: datetime, alarm: Alarm, *, sick: Callable[[date], bool] = is_sick +) -> Plan: + """Which morning is next, and whether it is one to wake the PC for. + + ``sick`` is asked last, so only a stick-day target that is not free ever + pays for the synced variant ``--arm`` passes. + """ target = now.date() wake_at = alarm_instant(alarm, target) - RTC_WAKE_LEAD if wake_at <= now: @@ -74,6 +94,8 @@ def plan(now: datetime, alarm: Alarm) -> Plan: ) if freedays.is_free_day(target): return Plan(target, wake_at, arm=False, reason="free day") + if sick(target): + return Plan(target, wake_at, arm=False, reason="sick day") return Plan(target, wake_at, arm=True, reason="stick day") @@ -139,10 +161,12 @@ def main(argv: list[str] | None = None) -> int: """Entry point.""" parser = mode_parser("Arm the RTC for a stick day", "arm", "arm (the timer)") args, now = parse_with_now(parser, argv, _now) - chosen = plan(now, get_alarm()) if args.arm: configure_logging(component="rtc") + cancel_for_sick_day() + chosen = plan(now, get_alarm(), sick=is_sick_synced) return 0 if apply(chosen) or not chosen.arm else 1 + chosen = plan(now, get_alarm()) sys.stdout.write(describe(chosen)) record = read_record() if not chosen.arm and record is not None and current_alarm() == record.epoch: diff --git a/wake_alarm/_session.py b/wake_alarm/_session.py index e49de4e..6120cc8 100644 --- a/wake_alarm/_session.py +++ b/wake_alarm/_session.py @@ -42,6 +42,7 @@ from wake_alarm._session_payload import newest_session from wake_alarm._session_policy import to_entry from wake_alarm._sync import get_alarm 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 _logger = logging.getLogger(__name__) @@ -84,8 +85,11 @@ def refresh(now: datetime) -> 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. + 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. """ + _cancel_for_sick_day() published = fetch_firebase_logs() if published is None: _logger.info("Morning session not refreshed: backend unreadable") diff --git a/wake_alarm/_sick.py b/wake_alarm/_sick.py new file mode 100644 index 0000000..86d6263 --- /dev/null +++ b/wake_alarm/_sick.py @@ -0,0 +1,54 @@ +"""Sick days: the one place wake_alarm asks the shared ``sickdays`` pool. + +A sick day (``~/src/utils/sickdays``) is not a free day -- a separate pool, +no cap, no reason -- but on the PC it stands down the same things: no +fallback ring, no RTC self-wake, no workday penalty, no morning routine. + +Two rules this module exists to keep in one spot: + +* **Fail closed.** ``sickdays.is_sick_day`` never raises and reads a corrupt + pool as "not sick". The import is guarded as well: these jobs run under + ``/usr/bin/python``, and a package that failed to install must leave + enforcement *on* (not sick), never crash the per-minute tick -- a crashed + tick rings nothing. +* **Sync only where something is about to happen, never in a read-only + mode.** ``sync_quietly`` rewrites the local pool, so ``--dry-run`` and + ``--show`` never call it. The sickdays timer alone is not enough: it is a + monotonic 15-minute timer, which pauses during hibernate and has no + ``Persistent`` catch-up, so right after the RTC resume the pool can be a + whole interval stale -- exactly when a day marked sick from bed matters. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +_logger = logging.getLogger(__name__) + +try: + import sickdays + + _AVAILABLE = True +except ImportError: + _AVAILABLE = False + _logger.warning("sickdays is not installed; no day counts as sick") + +if TYPE_CHECKING: + from datetime import date + + +def is_sick(day: date) -> bool: + """Whether ``day`` is a sick day; local file only, never raises.""" + return _AVAILABLE and sickdays.is_sick_day(day) + + +def sync() -> bool: + """Pull the pool from Firebase now; False when it could not (silently).""" + return _AVAILABLE and sickdays.sync_quietly() + + +def is_sick_synced(day: date) -> bool: + """:func:`is_sick` after a fresh :func:`sync` -- for act modes only.""" + sync() + return is_sick(day) diff --git a/wake_alarm/_trigger.py b/wake_alarm/_trigger.py index c2df2bf..2d581a6 100644 --- a/wake_alarm/_trigger.py +++ b/wake_alarm/_trigger.py @@ -35,6 +35,7 @@ from wake_alarm._constants import ( TRIGGER_TARGET_UNIT, ) from wake_alarm._logging_setup import configure_logging +from wake_alarm._sick import is_sick from wake_alarm._state import ( save_trigger_fired, was_trigger_fired_today, @@ -84,12 +85,13 @@ def _skip_reason(now: datetime) -> str | None: """Return why the alarm must not fire right now, or None if it may. Checks only the gates that are independent of the clock: the shared - free-day pool, the day gate and the once-per-day latch. + free-day and sick-day pools, the day gate and the once-per-day latch. A free day means the alarm does not ring at all -- the orchestrator is never started, so neither the alarm nor the workout lock behind it - appears. The pool is one local file read, never the network, so this - stays a millisecond-scale tick. + appears. A sick day is the same. Each pool is one local file read, never + the network, so this stays a millisecond-scale tick; the fallback tick + pulls the sick-day pool from ``A - 5 min``, so it is fresh by the fire. Args: now: Current local time. @@ -99,6 +101,8 @@ def _skip_reason(now: datetime) -> str | None: """ if freedays.is_free_day(now.date()): return "free day" + if is_sick(now.date()): + return "sick day" if now.weekday() not in ALARM_DAYS: return "not an alarm day" if was_trigger_fired_today(): diff --git a/wake_alarm/_workday_penalty.py b/wake_alarm/_workday_penalty.py index 875f993..15d7ebf 100644 --- a/wake_alarm/_workday_penalty.py +++ b/wake_alarm/_workday_penalty.py @@ -15,7 +15,11 @@ today == penalty_date means "no leniency today," the same shape as ``morning_session.json``'s own ``exempt_until`` predicate. An escape (``DOCS-wake-enforcement.md``) for a morning means no penalty is -written for it, and one already issued that day is deleted. +written for it, and one already issued that day is deleted. A sick day +(``_sick``) is the same, for its own reason: nothing is written on a sick +day, and a penalty *issued* on a day marked sick afterwards (a miss at 08:15, +"sick" at 14:00) is deleted by the next ``--refresh`` or RTC tick. Other +readers must not wait for that: see ``DOCS-wake-enforcement.md``. """ from __future__ import annotations @@ -30,6 +34,7 @@ from gatelock.log_integrity import compute_entry_hmac from wake_alarm._constants import WORKDAY_PENALTY_FILE from wake_alarm._events import emit from wake_alarm._session_policy import NONE_OUTCOME +from wake_alarm._sick import is_sick, is_sick_synced _logger = logging.getLogger(__name__) @@ -95,6 +100,22 @@ def issued_date() -> str | None: return issued if isinstance(issued, str) else None +def _cancel(issued: str, cause: str) -> bool: + """Delete the penalty issued on ``issued``; log ``penalty_cancelled``.""" + try: + WORKDAY_PENALTY_FILE.unlink() + except OSError as exc: + _logger.warning("Could not cancel %s: %s", WORKDAY_PENALTY_FILE, exc) + return False + emit( + "penalty_cancelled", + f"{cause.replace('_', ' ')} for {issued}", + issued_date=issued, + cause=cause, + ) + return True + + def cancel_for_escapes(escaped_dates: frozenset[str]) -> bool: """Delete the penalty when an escape excuses the morning it was issued. @@ -104,13 +125,47 @@ def cancel_for_escapes(escaped_dates: frozenset[str]) -> bool: issued = issued_date() if issued is None or issued not in escaped_dates: return False - try: - WORKDAY_PENALTY_FILE.unlink() - except OSError as exc: - _logger.warning("Could not cancel %s: %s", WORKDAY_PENALTY_FILE, exc) + return _cancel(issued, "escape") + + +def cancel_for_sick_day() -> bool: + """Delete the penalty when the morning it was issued is now a sick day. + + Local pool only: callers on a timer keep it fresh. Fail closed -- an + unreadable pool or date keeps the penalty. + + Returns: + True when a penalty was removed. + """ + issued = issued_date() + if issued is None: return False - emit("penalty_cancelled", f"escape for {issued}", issued_date=issued) - return True + try: + day = date.fromisoformat(issued) + except ValueError: + return False + return is_sick(day) and _cancel(issued, "sick_day") + + +def skip_reason( + entry: dict[str, object], + now: datetime, + *, + escaped_dates: frozenset[str] = frozenset(), +) -> str | None: + """Why today earns no penalty, or None when it does. Pure and local.""" + today = now.date() + if today.isoformat() in escaped_dates: + return "escaped" + if freedays.is_free_day(today): + return "free day" + if not is_stick_day(today): + return "not a stick day" + if is_sick(today): + return "sick day" + if not is_workday_miss(entry, now): + return "no miss" + return None def maybe_apply( @@ -126,19 +181,19 @@ def maybe_apply( would otherwise have rung the alarm -- the pool is fail-closed the other direction (a corrupt read is "not free"), which is exactly what is wanted here too: an unreadable pool means no penalty, never a spurious - one. An escaped morning is never penalised either. + one. An escaped morning or a sick day is never penalised either; the + sick-day pool is pulled once more before the day's *first* write, so a + day marked sick on the phone minutes ago still counts. ``penalty_written`` is logged once per issued day, not on every two-minute refresh that rewrites the same entry. """ + if skip_reason(entry, now, escaped_dates=escaped_dates) is not None: + return False today = now.date() - if today.isoformat() in escaped_dates: - return False - if freedays.is_free_day(today) or not is_stick_day(today): - return False - if not is_workday_miss(entry, now): - return False previously = issued_date() + if previously != today.isoformat() and is_sick_synced(today): + return False entry_body = penalty_entry(today=today) if not write_penalty(entry_body): return False diff --git a/wake_alarm/tests/conftest.py b/wake_alarm/tests/conftest.py index 871d6fb..30ea4cb 100644 --- a/wake_alarm/tests/conftest.py +++ b/wake_alarm/tests/conftest.py @@ -19,6 +19,8 @@ an inhibitor: ``~/.local/state/wake_alarm/events.jsonl``. Unredirected, every suite run appended fixture warnings ("Synced alarm out of range: 24:0") to the log a failed wake-up is reconstructed from. +* The free-day and sick-day pools live under ``~/.local/share``; both are + redirected, and the sick-day sync is stubbed offline. * Wake enforcement v2 writes more state (the fallback latch, the event state, incident reports) and reaches real hardware (``pw-play``, ``sudo systemctl``, ``rtcwake``, ``journalctl``). Every path is redirected @@ -34,6 +36,7 @@ from unittest.mock import patch import freedays import pytest +import sickdays if TYPE_CHECKING: from collections.abc import Generator @@ -100,6 +103,15 @@ def _hermetic(tmp_path: Path) -> Generator[None]: "freedays._api.resolve_paths", lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"), ), + # Same for the sick-day pool. Its sync is stubbed to "offline": the + # real ~/.config/crdt-sync credential exists on this machine, so an + # unstubbed act-mode tick would talk to live Firebase and rewrite the + # real pool. + patch( + "sickdays._api.resolve_paths", + lambda paths: paths or sickdays.Paths.under(tmp_path / "sickdays"), + ), + patch("sickdays.sync_quietly", lambda **_: False), ] with ExitStack() as stack: for redirect in patches: diff --git a/wake_alarm/tests/test_fallback_observe.py b/wake_alarm/tests/test_fallback_observe.py index af989c5..9b2329e 100644 --- a/wake_alarm/tests/test_fallback_observe.py +++ b/wake_alarm/tests/test_fallback_observe.py @@ -45,7 +45,7 @@ def _alarm() -> Generator[None]: 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()) + seen = observe(datetime(2026, 10, 1, 5, 0).astimezone(), sync=True) fetch.assert_not_called() assert seen.reachable assert seen.view is None @@ -57,13 +57,13 @@ def test_a_free_day_never_fetches() -> None: patch.object(_fallback_observe, "fetch_firebase_logs", fetch), patch.object(_fallback_observe.freedays, "is_free_day", return_value=True), ): - assert observe(NOW).facts.free_day + 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) + seen = observe(NOW, sync=False) assert not seen.reachable facts = seen.facts assert not (facts.escaped) @@ -81,7 +81,7 @@ def test_facts_from_the_phone() -> None: with patch.object( _fallback_observe, "fetch_firebase_logs", return_value=[("p", raw)] ): - seen = observe(NOW) + seen = observe(NOW, sync=False) assert seen.facts.session_started assert seen.facts.ring_started assert seen.facts.escaped @@ -95,7 +95,7 @@ def test_yesterdays_session_does_not_count() -> None: "fetch_firebase_logs", return_value=[("p", _log(morning_sessions=[session]))], ): - assert not observe(NOW).facts.session_started + assert not observe(NOW, sync=False).facts.session_started def test_settled_from_latch_or_event_state() -> None: @@ -123,12 +123,12 @@ def test_record_logs_unreachable_once_and_resets( patch.object(_fallback_observe, "fetch_firebase_logs", return_value=None), caplog.at_level(logging.INFO), ): - seen = observe(NOW) + 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)) + record(observe(NOW, sync=False)) assert last_value("firebase_unreachable") is None @@ -137,5 +137,5 @@ def test_record_notes_escapes() -> None: with patch.object( _fallback_observe, "fetch_firebase_logs", return_value=[("p", raw)] ): - record(observe(NOW)) + record(observe(NOW, sync=False)) assert last_value("escape_seen") == "e" diff --git a/wake_alarm/tests/test_sick_days.py b/wake_alarm/tests/test_sick_days.py new file mode 100644 index 0000000..a00569b --- /dev/null +++ b/wake_alarm/tests/test_sick_days.py @@ -0,0 +1,159 @@ +"""Sick days stand the PC down: no fallback ring, no RTC wake, no trigger. + +The pool is the real ``sickdays`` package writing into ``tmp_path`` +(``conftest`` redirects it); only the network sync is stubbed. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +import importlib +import sys +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest +import sickdays + +from wake_alarm import _fallback, _fallback_observe, _rtc, _sick, _trigger +from wake_alarm._fallback_latch import mark_started +from wake_alarm._fallback_policy import SICK_DAY, Decision, Facts, decide, stop_reason +from wake_alarm._sync import Alarm + +if TYPE_CHECKING: + from collections.abc import Generator + +TUESDAY = date(2026, 10, 6) +A = datetime(2026, 10, 6, 7, 0).astimezone() +MONDAY_EVENING = datetime(2026, 10, 5, 22, 0).astimezone() + + +def mark_sick(day: date) -> None: + """Put ``day`` in the (redirected) pool; may be in the past.""" + sickdays.import_legacy([day]) + + +def pull_marks_tuesday() -> bool: + """A sync that brings TUESDAY in from another device.""" + mark_sick(TUESDAY) + return True + + +@pytest.fixture +def sync() -> Generator[MagicMock]: + """The network pull, observed; reports success.""" + with patch("sickdays.sync_quietly", return_value=True) as pulled: + yield pulled + + +class TestSickModule: + def test_reads_the_pool(self) -> None: + assert not _sick.is_sick(TUESDAY) + mark_sick(TUESDAY) + assert _sick.is_sick(TUESDAY) + + def test_synced_pulls_first(self, sync: MagicMock) -> None: + sync.side_effect = pull_marks_tuesday + assert _sick.is_sick_synced(TUESDAY) + sync.assert_called_once_with() + + def test_missing_package_is_never_sick(self) -> None: + try: + with patch.dict(sys.modules, {"sickdays": None}): + reloaded = importlib.reload(_sick) + assert reloaded._AVAILABLE is False + assert not reloaded.is_sick(TUESDAY) + assert not reloaded.sync() + finally: + importlib.reload(_sick) + assert _sick._AVAILABLE is True + + +class TestFallbackPolicy: + def test_a_sick_day_never_rings(self) -> None: + facts = Facts(now=A + timedelta(minutes=20), alarm_at=A, free_day=False) + assert decide(facts).ring + sick = Facts( + now=A + timedelta(minutes=20), alarm_at=A, free_day=False, sick_day=True + ) + assert decide(sick) == Decision(ring=False, reason=SICK_DAY) + assert stop_reason(sick) == SICK_DAY + + +@pytest.fixture +def alarm_seven() -> Generator[None]: + with ( + patch.object(_fallback_observe, "get_alarm", return_value=Alarm(7, 0, "c")), + patch.object(_fallback, "get_alarm", return_value=Alarm(7, 0, "c")), + ): + yield + + +@pytest.mark.usefixtures("alarm_seven") +class TestFallback: + def test_in_window_act_pulls_and_stays_offline(self, sync: MagicMock) -> None: + sync.side_effect = pull_marks_tuesday + fetch = MagicMock() + with patch.object(_fallback_observe, "fetch_firebase_logs", fetch): + seen = _fallback_observe.observe(A + timedelta(minutes=3), sync=True) + assert seen.facts.sick_day + sync.assert_called_once_with() + fetch.assert_not_called() + + def test_dry_run_never_pulls(self, sync: MagicMock) -> None: + mark_sick(TUESDAY) + seen = _fallback_observe.observe(A + timedelta(minutes=3), sync=False) + assert seen.facts.sick_day + sync.assert_not_called() + + def test_off_window_tick_never_pulls(self, sync: MagicMock) -> None: + _fallback_observe.observe(A - timedelta(hours=2), sync=True) + sync.assert_not_called() + + def test_a_ring_in_progress_stops(self) -> None: + mark_sick(TUESDAY) + mark_started(None, day=TUESDAY.isoformat(), now=A, reason="phone_silent") + with patch.object(_fallback, "ring_now") as ring_now: + decision = _fallback.tick(A + timedelta(minutes=5)) + assert decision == Decision(ring=False, reason=SICK_DAY) + ring_now.assert_not_called() + latch = _fallback.read_latch(TUESDAY.isoformat()) + assert latch is not None + assert latch.stop_reason == SICK_DAY + + def test_reports_say_sick(self, capsys: pytest.CaptureFixture[str]) -> None: + mark_sick(TUESDAY) + _fallback.main(["--dry-run", "--at", "2026-10-06T07:05:00"]) + assert "quiet: sick_day" in capsys.readouterr().out + with patch.object(_fallback, "_now", return_value=A): + _fallback.main(["--show"]) + assert "sick_day=True" in capsys.readouterr().out + + +class TestRtc: + def test_a_sick_stick_day_is_skipped(self) -> None: + assert _rtc.plan(MONDAY_EVENING, Alarm(7, 0, "c")).arm + mark_sick(TUESDAY) + chosen = _rtc.plan(MONDAY_EVENING, Alarm(7, 0, "c")) + assert (chosen.arm, chosen.reason) == (False, "sick day") + + def test_arm_pulls_dry_run_does_not(self, sync: MagicMock) -> None: + sync.side_effect = pull_marks_tuesday + with ( + patch.object(_rtc, "get_alarm", return_value=Alarm(7, 0, "c")), + patch.object(_rtc, "_now", return_value=MONDAY_EVENING), + patch.object(_rtc, "configure_logging"), + patch.object(_rtc, "cancel_for_sick_day") as retract, + ): + assert _rtc.main(["--dry-run"]) == 0 + sync.assert_not_called() + retract.assert_not_called() + assert _rtc.main(["--arm"]) == 0 # a skip is not a failure + sync.assert_called_once_with() + retract.assert_called_once_with() + + +def test_the_trigger_skips_a_sick_day() -> None: + assert _trigger._skip_reason(A) is None + mark_sick(TUESDAY) + assert _trigger._skip_reason(A) == "sick day" diff --git a/wake_alarm/tests/test_sick_days_penalty.py b/wake_alarm/tests/test_sick_days_penalty.py new file mode 100644 index 0000000..5f20b9a --- /dev/null +++ b/wake_alarm/tests/test_sick_days_penalty.py @@ -0,0 +1,175 @@ +"""Sick days and the workday penalty, the session refresh and the audit.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import date, datetime +import json +import logging +from pathlib import Path +from unittest.mock import patch + +import pytest +import sickdays + +from wake_alarm import _audit, _session, _workday_penalty +from wake_alarm._audit_checks import ( + check_lockdown, + check_penalty, + check_ring, + check_rtc, + check_up_by_alarm, +) +from wake_alarm._audit_evidence import Evidence, PcSide, PhoneSide +from wake_alarm._enforcement_records import PhoneEvent +from wake_alarm._sync import Alarm +from wake_alarm._workday_penalty import ( + cancel_for_sick_day, + issued_date, + maybe_apply, + penalty_entry, + skip_reason, + write_penalty, +) + +TUESDAY = date(2026, 9, 22) +AFTER_GRACE = datetime(2026, 9, 22, 9, 0).astimezone() +MISS: dict[str, object] = { + "outcome": "none", + "exempt_until": datetime(2026, 9, 22, 8, 15).astimezone().isoformat(), +} + + +def mark_sick(day: date) -> None: + """Put ``day`` in the (redirected) pool; may be in the past.""" + sickdays.import_legacy([day]) + + +def pull_marks_tuesday() -> bool: + """A sync that brings TUESDAY in from another device.""" + mark_sick(TUESDAY) + return True + + +@pytest.fixture +def issued() -> None: + """A penalty issued on TUESDAY, on disk.""" + assert write_penalty(penalty_entry(today=TUESDAY)) + + +class TestWriting: + def test_a_sick_day_is_never_penalised(self) -> None: + assert skip_reason(MISS, AFTER_GRACE) is None + mark_sick(TUESDAY) + assert skip_reason(MISS, AFTER_GRACE) == "sick day" + assert not maybe_apply(MISS, AFTER_GRACE) + assert issued_date() is None + + def test_the_first_write_pulls_the_pool(self) -> None: + with patch("sickdays.sync_quietly", side_effect=pull_marks_tuesday) as pulled: + assert not maybe_apply(MISS, AFTER_GRACE) + pulled.assert_called_once_with() + assert issued_date() is None + + def test_a_rewrite_does_not_pull_again(self) -> None: + assert maybe_apply(MISS, AFTER_GRACE) + with patch("sickdays.sync_quietly") as pulled: + assert maybe_apply(MISS, AFTER_GRACE) + pulled.assert_not_called() + + def test_other_skip_reasons(self) -> None: + assert skip_reason(MISS, AFTER_GRACE, escaped_dates=frozenset({"2026-09-22"})) + monday = datetime(2026, 9, 21, 9, 0).astimezone() + assert skip_reason(MISS, monday) == "not a stick day" + assert skip_reason({"outcome": "completed"}, AFTER_GRACE) == "no miss" + with patch.object(_workday_penalty.freedays, "is_free_day", return_value=True): + assert skip_reason(MISS, AFTER_GRACE) == "free day" + + +@pytest.mark.usefixtures("issued") +class TestRetraction: + def test_kept_while_not_sick(self) -> None: + assert not cancel_for_sick_day() + assert issued_date() == TUESDAY.isoformat() + + def test_retracted_once_the_issuing_day_is_sick( + self, caplog: pytest.LogCaptureFixture + ) -> None: + mark_sick(TUESDAY) + with caplog.at_level(logging.INFO): + assert cancel_for_sick_day() + assert issued_date() is None + assert "penalty_cancelled: sick day for 2026-09-22" in caplog.text + + def test_a_sick_penalty_date_alone_does_not_retract(self) -> None: + mark_sick(date(2026, 9, 23)) + assert not cancel_for_sick_day() + + def test_unlink_failure_keeps_it(self) -> None: + mark_sick(TUESDAY) + with patch.object(Path, "unlink", side_effect=OSError("ro")): + assert not cancel_for_sick_day() + + def test_garbage_issued_date_is_kept(self) -> None: + _workday_penalty.WORKDAY_PENALTY_FILE.write_text( + json.dumps({"issued_date": "soon"}) + ) + assert not cancel_for_sick_day() + + def test_refresh_retracts_even_offline(self) -> None: + mark_sick(TUESDAY) + with patch.object(_session, "fetch_firebase_logs", return_value=None): + assert _session.refresh(AFTER_GRACE) is None + assert issued_date() is None + + +def test_no_penalty_file_nothing_to_retract() -> None: + assert not cancel_for_sick_day() + + +def _ev(**overrides: object) -> Evidence: + pc = PcSide(penalty_issued=overrides.pop("penalty_issued", None)) + phone = PhoneSide(events=overrides.pop("phone", [])) + base = Evidence( + day=TUESDAY, + stick_day=True, + free_day=False, + alarm_at=AFTER_GRACE, + alarm_source="t", + pc_side=pc, + phone_side=phone, + sick_day=True, + ) + return replace(base, **overrides) + + +class TestAudit: + def test_nothing_is_expected(self) -> None: + for check in (check_rtc, check_up_by_alarm, check_ring, check_lockdown): + result = check(_ev()) + assert (result.ok, result.detail) == (None, "sick day") + + def test_penalty(self) -> None: + assert check_penalty(_ev()).ok is True + assert check_penalty(_ev(penalty_issued="2026-09-22")).ok is False + + def test_a_lockdown_for_a_sick_miss_is_flagged(self) -> None: + at = AFTER_GRACE.isoformat() + phone = [ + PhoneEvent(at, "stick_check", {"date": "2026-09-22", "verdict": "miss"}), + PhoneEvent(at, "lockdown_sent", {"until": "2026-09-23"}), + ] + assert check_lockdown(_ev(phone=phone)).ok is False + assert check_lockdown(_ev(phone=phone[:1])).ok is None + + def test_the_report_names_the_day(self) -> None: + mark_sick(TUESDAY) + with ( + patch.object(_audit, "fetch_firebase_logs", return_value=[]), + patch.object(_audit, "get_alarm", return_value=Alarm(7, 0, "cache")), + patch.object(_audit, "boots", return_value=[]), + patch.object(_audit, "resumes", return_value=[]), + ): + evidence, _ = _audit.gather(TUESDAY) + assert evidence.sick_day + assert "(Tue, sick day)" in _audit.render(evidence, [], reachable=True)