diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 000a9c3..2daebb9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -108,7 +108,7 @@ repos: - 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.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 + - sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.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/DOCS-wake-enforcement.md b/DOCS-wake-enforcement.md index 34af19e..a51478c 100644 --- a/DOCS-wake-enforcement.md +++ b/DOCS-wake-enforcement.md @@ -78,7 +78,7 @@ 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`): +A sick day (that does not keep `alarm`, see below) 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): @@ -109,12 +109,21 @@ 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. +must treat the file as void when **either** its `issued_date` is a day the +*alarm* stands down on (`sickdays.stands_down(sickdays.LOCKER_ALARM, +date.fromisoformat(issued_date))`) **or** its `penalty_date` is a day *their +own* locker stands down on (`stands_down(LOCKER_WORKOUT, ...)` for +screen-locker, `LOCKER_LEETCODE` for leetcode-guard). `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; the ring day belongs to the alarm switch, the penalty day to whatever +the penalty charges. + +**Keeping the alarm (`sickdays` 0.2.0).** A sick day may keep lockers +(`sickdays add --keep alarm`). Everything in the table above is the single +`alarm` switch (`_sick.alarm_stands_down`): on a sick day that keeps `alarm` +the fallback ring, RTC self-wake, trigger, workday penalty (written, and not +retracted) and audit behave exactly as on a normal stick day. **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 diff --git a/pyproject.toml b/pyproject.toml index 701d2e1..d337712 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "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", + "sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.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 7665d35..c2230b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,4 @@ pytest-randomly==5.0.0 pytest-sugar==1.1.1 pytest-xdist==3.8.0 ruff==0.17.1 -sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays +sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays diff --git a/wake_alarm/_audit.py b/wake_alarm/_audit.py index 1d8e2c9..6df69d1 100644 --- a/wake_alarm/_audit.py +++ b/wake_alarm/_audit.py @@ -36,7 +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._sick import alarm_stands_down from wake_alarm._sync import get_alarm from wake_alarm._sync_transport import fetch_firebase_logs from wake_alarm._workday_penalty import issued_date @@ -65,7 +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), + sick_day=alarm_stands_down(day), alarm_at=alarm_at, alarm_source=source, pc_side=PcSide( diff --git a/wake_alarm/_fallback.py b/wake_alarm/_fallback.py index 55487d5..29a72cf 100644 --- a/wake_alarm/_fallback.py +++ b/wake_alarm/_fallback.py @@ -44,7 +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._sick import alarm_stands_down from wake_alarm._sync import get_alarm if TYPE_CHECKING: @@ -150,7 +150,7 @@ def show(now: datetime) -> str: return ( f"today {day}: A={alarm_at:%H:%M} " f"free_day={freedays.is_free_day(now.date())} " - f"sick_day={is_sick(now.date())}\n" + f"sick_day={alarm_stands_down(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" diff --git a/wake_alarm/_fallback_observe.py b/wake_alarm/_fallback_observe.py index 2bab023..5282e57 100644 --- a/wake_alarm/_fallback_observe.py +++ b/wake_alarm/_fallback_observe.py @@ -23,7 +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._sick import alarm_stands_down, alarm_stands_down_synced from wake_alarm._sync import get_alarm from wake_alarm._sync_transport import fetch_firebase_logs @@ -58,8 +58,8 @@ def _sick_today(now: datetime, *, online: bool, sync: bool) -> bool: 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()) + return alarm_stands_down_synced(now.date()) + return alarm_stands_down(now.date()) def observe(now: datetime, *, sync: bool) -> Observation: diff --git a/wake_alarm/_rtc.py b/wake_alarm/_rtc.py index 7bd1e59..ba069a9 100644 --- a/wake_alarm/_rtc.py +++ b/wake_alarm/_rtc.py @@ -47,7 +47,7 @@ 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._sick import alarm_stands_down, alarm_stands_down_synced from wake_alarm._sync import Alarm, get_alarm from wake_alarm._workday_penalty import cancel_for_sick_day @@ -76,7 +76,7 @@ def _now() -> datetime: def plan( - now: datetime, alarm: Alarm, *, sick: Callable[[date], bool] = is_sick + now: datetime, alarm: Alarm, *, sick: Callable[[date], bool] = alarm_stands_down ) -> Plan: """Which morning is next, and whether it is one to wake the PC for. @@ -164,7 +164,7 @@ def main(argv: list[str] | None = None) -> int: if args.arm: configure_logging(component="rtc") cancel_for_sick_day() - chosen = plan(now, get_alarm(), sick=is_sick_synced) + chosen = plan(now, get_alarm(), sick=alarm_stands_down_synced) return 0 if apply(chosen) or not chosen.arm else 1 chosen = plan(now, get_alarm()) sys.stdout.write(describe(chosen)) diff --git a/wake_alarm/_sick.py b/wake_alarm/_sick.py index 86d6263..8b031bf 100644 --- a/wake_alarm/_sick.py +++ b/wake_alarm/_sick.py @@ -4,12 +4,17 @@ 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. +All of that is **one switch**: the ``alarm`` locker. A sick day may *keep* +the alarm (``sickdays`` 0.2.0), and then every piece above behaves exactly as +on a normal day, so the question this module answers is +``stands_down(LOCKER_ALARM, day)``, never plain ``is_sick_day``. + Two rules this module exists to keep in one spot: -* **Fail closed.** ``sickdays.is_sick_day`` never raises and reads a corrupt +* **Fail closed.** ``sickdays.stands_down`` 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 + enforcement *on* (not standing down), 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 @@ -38,9 +43,12 @@ 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 alarm_stands_down(day: date) -> bool: + """Whether the alarm is switched off on ``day``; local file, never raises. + + True iff ``day`` is sick and its keep set does not hold the alarm. + """ + return _AVAILABLE and sickdays.stands_down(sickdays.LOCKER_ALARM, day) def sync() -> bool: @@ -48,7 +56,7 @@ def sync() -> bool: 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.""" +def alarm_stands_down_synced(day: date) -> bool: + """:func:`alarm_stands_down` after a fresh :func:`sync` -- act modes only.""" sync() - return is_sick(day) + return alarm_stands_down(day) diff --git a/wake_alarm/_trigger.py b/wake_alarm/_trigger.py index 2d581a6..ddb0711 100644 --- a/wake_alarm/_trigger.py +++ b/wake_alarm/_trigger.py @@ -35,7 +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._sick import alarm_stands_down from wake_alarm._state import ( save_trigger_fired, was_trigger_fired_today, @@ -101,7 +101,7 @@ def _skip_reason(now: datetime) -> str | None: """ if freedays.is_free_day(now.date()): return "free day" - if is_sick(now.date()): + if alarm_stands_down(now.date()): return "sick day" if now.weekday() not in ALARM_DAYS: return "not an alarm day" diff --git a/wake_alarm/_workday_penalty.py b/wake_alarm/_workday_penalty.py index 15d7ebf..552b924 100644 --- a/wake_alarm/_workday_penalty.py +++ b/wake_alarm/_workday_penalty.py @@ -34,7 +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 +from wake_alarm._sick import alarm_stands_down, alarm_stands_down_synced _logger = logging.getLogger(__name__) @@ -144,7 +144,7 @@ def cancel_for_sick_day() -> bool: day = date.fromisoformat(issued) except ValueError: return False - return is_sick(day) and _cancel(issued, "sick_day") + return alarm_stands_down(day) and _cancel(issued, "sick_day") def skip_reason( @@ -161,7 +161,7 @@ def skip_reason( return "free day" if not is_stick_day(today): return "not a stick day" - if is_sick(today): + if alarm_stands_down(today): return "sick day" if not is_workday_miss(entry, now): return "no miss" @@ -192,7 +192,7 @@ def maybe_apply( return False today = now.date() previously = issued_date() - if previously != today.isoformat() and is_sick_synced(today): + if previously != today.isoformat() and alarm_stands_down_synced(today): return False entry_body = penalty_entry(today=today) if not write_penalty(entry_body): diff --git a/wake_alarm/tests/test_sick_days.py b/wake_alarm/tests/test_sick_days.py index a00569b..220fb08 100644 --- a/wake_alarm/tests/test_sick_days.py +++ b/wake_alarm/tests/test_sick_days.py @@ -28,9 +28,9 @@ 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 mark_sick(day: date, *, keep: tuple[str, ...] = ()) -> None: + """Put ``day`` in the (redirected) pool, keeping ``keep``; may be past.""" + sickdays.add_sick_day(day, keep=keep, now=day) def pull_marks_tuesday() -> bool: @@ -48,13 +48,22 @@ def sync() -> Generator[MagicMock]: class TestSickModule: def test_reads_the_pool(self) -> None: - assert not _sick.is_sick(TUESDAY) + assert not _sick.alarm_stands_down(TUESDAY) mark_sick(TUESDAY) - assert _sick.is_sick(TUESDAY) + assert _sick.alarm_stands_down(TUESDAY) + + def test_a_kept_alarm_does_not_stand_down(self) -> None: + mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,)) + assert sickdays.is_sick_day(TUESDAY) + assert not _sick.alarm_stands_down(TUESDAY) + + def test_keeping_another_locker_does_not_keep_the_alarm(self) -> None: + mark_sick(TUESDAY, keep=(sickdays.LOCKER_WORKOUT, sickdays.LOCKER_LEETCODE)) + assert _sick.alarm_stands_down(TUESDAY) def test_synced_pulls_first(self, sync: MagicMock) -> None: sync.side_effect = pull_marks_tuesday - assert _sick.is_sick_synced(TUESDAY) + assert _sick.alarm_stands_down_synced(TUESDAY) sync.assert_called_once_with() def test_missing_package_is_never_sick(self) -> None: @@ -62,7 +71,7 @@ class TestSickModule: 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.alarm_stands_down(TUESDAY) assert not reloaded.sync() finally: importlib.reload(_sick) @@ -121,6 +130,21 @@ class TestFallback: assert latch is not None assert latch.stop_reason == SICK_DAY + def test_a_kept_alarm_rings_like_a_normal_day(self) -> None: + mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,)) + seen = _fallback_observe.observe(A + timedelta(minutes=3), sync=False) + assert not seen.facts.sick_day + + def test_a_kept_alarm_does_not_stop_a_ring_in_progress(self) -> None: + mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,)) + mark_started(None, day=TUESDAY.isoformat(), now=A, reason="phone_silent") + with patch.object(_fallback, "ring_now"): + decision = _fallback.tick(A + timedelta(minutes=5)) + assert decision.reason != SICK_DAY + 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"]) @@ -153,6 +177,17 @@ class TestRtc: retract.assert_called_once_with() +def test_a_kept_alarm_plans_the_rtc_wake() -> None: + mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,)) + chosen = _rtc.plan(MONDAY_EVENING, Alarm(7, 0, "c")) + assert (chosen.arm, chosen.reason) == (True, "stick day") + + +def test_a_kept_alarm_lets_the_trigger_fire() -> None: + mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,)) + assert _trigger._skip_reason(A) is None + + def test_the_trigger_skips_a_sick_day() -> None: assert _trigger._skip_reason(A) is None mark_sick(TUESDAY) diff --git a/wake_alarm/tests/test_sick_days_penalty.py b/wake_alarm/tests/test_sick_days_penalty.py index 5f20b9a..c5247e9 100644 --- a/wake_alarm/tests/test_sick_days_penalty.py +++ b/wake_alarm/tests/test_sick_days_penalty.py @@ -40,9 +40,9 @@ MISS: dict[str, object] = { } -def mark_sick(day: date) -> None: - """Put ``day`` in the (redirected) pool; may be in the past.""" - sickdays.import_legacy([day]) +def mark_sick(day: date, *, keep: tuple[str, ...] = ()) -> None: + """Put ``day`` in the (redirected) pool, keeping ``keep``; may be past.""" + sickdays.add_sick_day(day, keep=keep, now=day) def pull_marks_tuesday() -> bool: @@ -65,6 +65,16 @@ class TestWriting: assert not maybe_apply(MISS, AFTER_GRACE) assert issued_date() is None + def test_a_kept_alarm_is_penalised_like_a_normal_day(self) -> None: + mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,)) + assert skip_reason(MISS, AFTER_GRACE) is None + assert maybe_apply(MISS, AFTER_GRACE) + assert issued_date() == TUESDAY.isoformat() + + def test_a_kept_workout_does_not_keep_the_missed_ring(self) -> None: + mark_sick(TUESDAY, keep=(sickdays.LOCKER_WORKOUT,)) + assert skip_reason(MISS, AFTER_GRACE) == "sick day" + 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) @@ -101,6 +111,11 @@ class TestRetraction: assert issued_date() is None assert "penalty_cancelled: sick day for 2026-09-22" in caplog.text + def test_kept_alarm_keeps_the_penalty(self) -> None: + mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,)) + assert not cancel_for_sick_day() + assert issued_date() == TUESDAY.isoformat() + def test_a_sick_penalty_date_alone_does_not_retract(self) -> None: mark_sick(date(2026, 9, 23)) assert not cancel_for_sick_day() @@ -162,6 +177,18 @@ class TestAudit: assert check_lockdown(_ev(phone=phone)).ok is False assert check_lockdown(_ev(phone=phone[:1])).ok is None + def test_a_kept_alarm_is_a_stick_day_in_the_audit(self) -> None: + mark_sick(TUESDAY, keep=(sickdays.LOCKER_ALARM,)) + 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 not evidence.sick_day + assert "(Tue, stick day)" in _audit.render(evidence, [], reachable=True) + def test_the_report_names_the_day(self) -> None: mark_sick(TUESDAY) with (