mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
PC: sick days stand the alarm down only when it is not kept
All PC enforcement (fallback ring, RTC self-wake, trigger, workday penalty write/retract, audit) now asks sickdays.stands_down(LOCKER_ALARM, day) via _sick.alarm_stands_down instead of is_sick_day, so a sick day that keeps the alarm behaves exactly as a normal stick day. Bump sickdays pin to v0.2.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
This commit is contained in:
parent
2e594f041d
commit
5d0f06b19e
@ -108,7 +108,7 @@ repos:
|
|||||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
|
- 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
|
- 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
|
- 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
|
# Test suites intentionally use patterns (protected-access, magic-value
|
||||||
# comparisons) that don't belong in the source-code 10/10 gate.
|
# comparisons) that don't belong in the source-code 10/10 gate.
|
||||||
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
|
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
|
||||||
|
|||||||
@ -78,7 +78,7 @@ wake-alarm, todo) — narrower than the night list.
|
|||||||
|
|
||||||
### Sick days (PC side, 2026-10-16)
|
### 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,
|
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
|
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):
|
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).
|
the pool file).
|
||||||
|
|
||||||
**Other readers of `workday_penalty.json`** (screen-locker, leetcode-guard)
|
**Other readers of `workday_penalty.json`** (screen-locker, leetcode-guard)
|
||||||
must treat the file as void when **either** `issued_date` **or**
|
must treat the file as void when **either** its `issued_date` is a day the
|
||||||
`penalty_date` is a sick day (`sickdays.is_sick_day(date.fromisoformat(...))`).
|
*alarm* stands down on (`sickdays.stands_down(sickdays.LOCKER_ALARM,
|
||||||
`issued_date` covers a miss at 08:15 marked sick at 14:00 -- the deletion
|
date.fromisoformat(issued_date))`) **or** its `penalty_date` is a day *their
|
||||||
runs on the next RTC tick (<= 15 min) or the next morning's refresh, and a
|
own* locker stands down on (`stands_down(LOCKER_WORKOUT, ...)` for
|
||||||
reader must not depend on that; `penalty_date` is a sick day on which every
|
screen-locker, `LOCKER_LEETCODE` for leetcode-guard). `issued_date` covers a
|
||||||
locker stands down anyway.
|
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
|
**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
|
`data.reason = "sick_day"` (by analogy with `free_day`); the audit treats
|
||||||
|
|||||||
@ -16,7 +16,7 @@ dependencies = [
|
|||||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
|
"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
|
# Sick days stand the PC's enforcement down (_sick.py). Imported behind
|
||||||
# a guard: missing means "not sick", never a crashed tick.
|
# 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
|
# Package discovery must be explicit: phone_app/ is a Flutter app, not a Python
|
||||||
|
|||||||
@ -17,4 +17,4 @@ pytest-randomly==5.0.0
|
|||||||
pytest-sugar==1.1.1
|
pytest-sugar==1.1.1
|
||||||
pytest-xdist==3.8.0
|
pytest-xdist==3.8.0
|
||||||
ruff==0.17.1
|
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
|
||||||
|
|||||||
@ -36,7 +36,7 @@ from wake_alarm._enforcement_records import parse_instant
|
|||||||
from wake_alarm._events import emit
|
from wake_alarm._events import emit
|
||||||
from wake_alarm._fallback_policy import alarm_instant
|
from wake_alarm._fallback_policy import alarm_instant
|
||||||
from wake_alarm._logging_setup import configure_logging
|
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 import get_alarm
|
||||||
from wake_alarm._sync_transport import fetch_firebase_logs
|
from wake_alarm._sync_transport import fetch_firebase_logs
|
||||||
from wake_alarm._workday_penalty import issued_date
|
from wake_alarm._workday_penalty import issued_date
|
||||||
@ -65,7 +65,7 @@ def gather(day: date) -> tuple[Evidence, bool]:
|
|||||||
day=day,
|
day=day,
|
||||||
stick_day=freedays.is_workday(day),
|
stick_day=freedays.is_workday(day),
|
||||||
free_day=freedays.is_free_day(day),
|
free_day=freedays.is_free_day(day),
|
||||||
sick_day=is_sick(day),
|
sick_day=alarm_stands_down(day),
|
||||||
alarm_at=alarm_at,
|
alarm_at=alarm_at,
|
||||||
alarm_source=source,
|
alarm_source=source,
|
||||||
pc_side=PcSide(
|
pc_side=PcSide(
|
||||||
|
|||||||
@ -44,7 +44,7 @@ from wake_alarm._fallback_policy import (
|
|||||||
from wake_alarm._fallback_ring import ring
|
from wake_alarm._fallback_ring import ring
|
||||||
from wake_alarm._logging_setup import configure_logging
|
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._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
|
from wake_alarm._sync import get_alarm
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -150,7 +150,7 @@ def show(now: datetime) -> str:
|
|||||||
return (
|
return (
|
||||||
f"today {day}: A={alarm_at:%H:%M} "
|
f"today {day}: A={alarm_at:%H:%M} "
|
||||||
f"free_day={freedays.is_free_day(now.date())} "
|
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" latch={read_latch(day)}\n"
|
||||||
f" last fallback_tick={last_value('fallback_tick')}\n"
|
f" last fallback_tick={last_value('fallback_tick')}\n"
|
||||||
f" settled={settled_reason(day, read_latch(day))}\n"
|
f" settled={settled_reason(day, read_latch(day))}\n"
|
||||||
|
|||||||
@ -23,7 +23,7 @@ from wake_alarm._fallback_policy import (
|
|||||||
needs_facts,
|
needs_facts,
|
||||||
)
|
)
|
||||||
from wake_alarm._session_payload import newest_session
|
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 import get_alarm
|
||||||
from wake_alarm._sync_transport import fetch_firebase_logs
|
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.
|
the ring at the next poll, and an off-window tick stays local.
|
||||||
"""
|
"""
|
||||||
if online and sync:
|
if online and sync:
|
||||||
return is_sick_synced(now.date())
|
return alarm_stands_down_synced(now.date())
|
||||||
return is_sick(now.date())
|
return alarm_stands_down(now.date())
|
||||||
|
|
||||||
|
|
||||||
def observe(now: datetime, *, sync: bool) -> Observation:
|
def observe(now: datetime, *, sync: bool) -> Observation:
|
||||||
|
|||||||
@ -47,7 +47,7 @@ from wake_alarm._fallback_policy import alarm_instant
|
|||||||
from wake_alarm._logging_setup import configure_logging
|
from wake_alarm._logging_setup import configure_logging
|
||||||
from wake_alarm._pc_wake_mode import read_mode
|
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._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._sync import Alarm, get_alarm
|
||||||
from wake_alarm._workday_penalty import cancel_for_sick_day
|
from wake_alarm._workday_penalty import cancel_for_sick_day
|
||||||
|
|
||||||
@ -76,7 +76,7 @@ def _now() -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
def plan(
|
def plan(
|
||||||
now: datetime, alarm: Alarm, *, sick: Callable[[date], bool] = is_sick
|
now: datetime, alarm: Alarm, *, sick: Callable[[date], bool] = alarm_stands_down
|
||||||
) -> Plan:
|
) -> Plan:
|
||||||
"""Which morning is next, and whether it is one to wake the PC for.
|
"""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:
|
if args.arm:
|
||||||
configure_logging(component="rtc")
|
configure_logging(component="rtc")
|
||||||
cancel_for_sick_day()
|
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
|
return 0 if apply(chosen) or not chosen.arm else 1
|
||||||
chosen = plan(now, get_alarm())
|
chosen = plan(now, get_alarm())
|
||||||
sys.stdout.write(describe(chosen))
|
sys.stdout.write(describe(chosen))
|
||||||
|
|||||||
@ -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
|
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.
|
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:
|
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
|
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
|
``/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.
|
tick rings nothing.
|
||||||
* **Sync only where something is about to happen, never in a read-only
|
* **Sync only where something is about to happen, never in a read-only
|
||||||
mode.** ``sync_quietly`` rewrites the local pool, so ``--dry-run`` and
|
mode.** ``sync_quietly`` rewrites the local pool, so ``--dry-run`` and
|
||||||
@ -38,9 +43,12 @@ if TYPE_CHECKING:
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
def is_sick(day: date) -> bool:
|
def alarm_stands_down(day: date) -> bool:
|
||||||
"""Whether ``day`` is a sick day; local file only, never raises."""
|
"""Whether the alarm is switched off on ``day``; local file, never raises.
|
||||||
return _AVAILABLE and sickdays.is_sick_day(day)
|
|
||||||
|
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:
|
def sync() -> bool:
|
||||||
@ -48,7 +56,7 @@ def sync() -> bool:
|
|||||||
return _AVAILABLE and sickdays.sync_quietly()
|
return _AVAILABLE and sickdays.sync_quietly()
|
||||||
|
|
||||||
|
|
||||||
def is_sick_synced(day: date) -> bool:
|
def alarm_stands_down_synced(day: date) -> bool:
|
||||||
""":func:`is_sick` after a fresh :func:`sync` -- for act modes only."""
|
""":func:`alarm_stands_down` after a fresh :func:`sync` -- act modes only."""
|
||||||
sync()
|
sync()
|
||||||
return is_sick(day)
|
return alarm_stands_down(day)
|
||||||
|
|||||||
@ -35,7 +35,7 @@ from wake_alarm._constants import (
|
|||||||
TRIGGER_TARGET_UNIT,
|
TRIGGER_TARGET_UNIT,
|
||||||
)
|
)
|
||||||
from wake_alarm._logging_setup import configure_logging
|
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 (
|
from wake_alarm._state import (
|
||||||
save_trigger_fired,
|
save_trigger_fired,
|
||||||
was_trigger_fired_today,
|
was_trigger_fired_today,
|
||||||
@ -101,7 +101,7 @@ def _skip_reason(now: datetime) -> str | None:
|
|||||||
"""
|
"""
|
||||||
if freedays.is_free_day(now.date()):
|
if freedays.is_free_day(now.date()):
|
||||||
return "free day"
|
return "free day"
|
||||||
if is_sick(now.date()):
|
if alarm_stands_down(now.date()):
|
||||||
return "sick day"
|
return "sick day"
|
||||||
if now.weekday() not in ALARM_DAYS:
|
if now.weekday() not in ALARM_DAYS:
|
||||||
return "not an alarm day"
|
return "not an alarm day"
|
||||||
|
|||||||
@ -34,7 +34,7 @@ from gatelock.log_integrity import compute_entry_hmac
|
|||||||
from wake_alarm._constants import WORKDAY_PENALTY_FILE
|
from wake_alarm._constants import WORKDAY_PENALTY_FILE
|
||||||
from wake_alarm._events import emit
|
from wake_alarm._events import emit
|
||||||
from wake_alarm._session_policy import NONE_OUTCOME
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -144,7 +144,7 @@ def cancel_for_sick_day() -> bool:
|
|||||||
day = date.fromisoformat(issued)
|
day = date.fromisoformat(issued)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
return is_sick(day) and _cancel(issued, "sick_day")
|
return alarm_stands_down(day) and _cancel(issued, "sick_day")
|
||||||
|
|
||||||
|
|
||||||
def skip_reason(
|
def skip_reason(
|
||||||
@ -161,7 +161,7 @@ def skip_reason(
|
|||||||
return "free day"
|
return "free day"
|
||||||
if not is_stick_day(today):
|
if not is_stick_day(today):
|
||||||
return "not a stick day"
|
return "not a stick day"
|
||||||
if is_sick(today):
|
if alarm_stands_down(today):
|
||||||
return "sick day"
|
return "sick day"
|
||||||
if not is_workday_miss(entry, now):
|
if not is_workday_miss(entry, now):
|
||||||
return "no miss"
|
return "no miss"
|
||||||
@ -192,7 +192,7 @@ def maybe_apply(
|
|||||||
return False
|
return False
|
||||||
today = now.date()
|
today = now.date()
|
||||||
previously = issued_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
|
return False
|
||||||
entry_body = penalty_entry(today=today)
|
entry_body = penalty_entry(today=today)
|
||||||
if not write_penalty(entry_body):
|
if not write_penalty(entry_body):
|
||||||
|
|||||||
@ -28,9 +28,9 @@ A = datetime(2026, 10, 6, 7, 0).astimezone()
|
|||||||
MONDAY_EVENING = datetime(2026, 10, 5, 22, 0).astimezone()
|
MONDAY_EVENING = datetime(2026, 10, 5, 22, 0).astimezone()
|
||||||
|
|
||||||
|
|
||||||
def mark_sick(day: date) -> None:
|
def mark_sick(day: date, *, keep: tuple[str, ...] = ()) -> None:
|
||||||
"""Put ``day`` in the (redirected) pool; may be in the past."""
|
"""Put ``day`` in the (redirected) pool, keeping ``keep``; may be past."""
|
||||||
sickdays.import_legacy([day])
|
sickdays.add_sick_day(day, keep=keep, now=day)
|
||||||
|
|
||||||
|
|
||||||
def pull_marks_tuesday() -> bool:
|
def pull_marks_tuesday() -> bool:
|
||||||
@ -48,13 +48,22 @@ def sync() -> Generator[MagicMock]:
|
|||||||
|
|
||||||
class TestSickModule:
|
class TestSickModule:
|
||||||
def test_reads_the_pool(self) -> None:
|
def test_reads_the_pool(self) -> None:
|
||||||
assert not _sick.is_sick(TUESDAY)
|
assert not _sick.alarm_stands_down(TUESDAY)
|
||||||
mark_sick(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:
|
def test_synced_pulls_first(self, sync: MagicMock) -> None:
|
||||||
sync.side_effect = pull_marks_tuesday
|
sync.side_effect = pull_marks_tuesday
|
||||||
assert _sick.is_sick_synced(TUESDAY)
|
assert _sick.alarm_stands_down_synced(TUESDAY)
|
||||||
sync.assert_called_once_with()
|
sync.assert_called_once_with()
|
||||||
|
|
||||||
def test_missing_package_is_never_sick(self) -> None:
|
def test_missing_package_is_never_sick(self) -> None:
|
||||||
@ -62,7 +71,7 @@ class TestSickModule:
|
|||||||
with patch.dict(sys.modules, {"sickdays": None}):
|
with patch.dict(sys.modules, {"sickdays": None}):
|
||||||
reloaded = importlib.reload(_sick)
|
reloaded = importlib.reload(_sick)
|
||||||
assert reloaded._AVAILABLE is False
|
assert reloaded._AVAILABLE is False
|
||||||
assert not reloaded.is_sick(TUESDAY)
|
assert not reloaded.alarm_stands_down(TUESDAY)
|
||||||
assert not reloaded.sync()
|
assert not reloaded.sync()
|
||||||
finally:
|
finally:
|
||||||
importlib.reload(_sick)
|
importlib.reload(_sick)
|
||||||
@ -121,6 +130,21 @@ class TestFallback:
|
|||||||
assert latch is not None
|
assert latch is not None
|
||||||
assert latch.stop_reason == SICK_DAY
|
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:
|
def test_reports_say_sick(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
mark_sick(TUESDAY)
|
mark_sick(TUESDAY)
|
||||||
_fallback.main(["--dry-run", "--at", "2026-10-06T07:05:00"])
|
_fallback.main(["--dry-run", "--at", "2026-10-06T07:05:00"])
|
||||||
@ -153,6 +177,17 @@ class TestRtc:
|
|||||||
retract.assert_called_once_with()
|
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:
|
def test_the_trigger_skips_a_sick_day() -> None:
|
||||||
assert _trigger._skip_reason(A) is None
|
assert _trigger._skip_reason(A) is None
|
||||||
mark_sick(TUESDAY)
|
mark_sick(TUESDAY)
|
||||||
|
|||||||
@ -40,9 +40,9 @@ MISS: dict[str, object] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def mark_sick(day: date) -> None:
|
def mark_sick(day: date, *, keep: tuple[str, ...] = ()) -> None:
|
||||||
"""Put ``day`` in the (redirected) pool; may be in the past."""
|
"""Put ``day`` in the (redirected) pool, keeping ``keep``; may be past."""
|
||||||
sickdays.import_legacy([day])
|
sickdays.add_sick_day(day, keep=keep, now=day)
|
||||||
|
|
||||||
|
|
||||||
def pull_marks_tuesday() -> bool:
|
def pull_marks_tuesday() -> bool:
|
||||||
@ -65,6 +65,16 @@ class TestWriting:
|
|||||||
assert not maybe_apply(MISS, AFTER_GRACE)
|
assert not maybe_apply(MISS, AFTER_GRACE)
|
||||||
assert issued_date() is None
|
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:
|
def test_the_first_write_pulls_the_pool(self) -> None:
|
||||||
with patch("sickdays.sync_quietly", side_effect=pull_marks_tuesday) as pulled:
|
with patch("sickdays.sync_quietly", side_effect=pull_marks_tuesday) as pulled:
|
||||||
assert not maybe_apply(MISS, AFTER_GRACE)
|
assert not maybe_apply(MISS, AFTER_GRACE)
|
||||||
@ -101,6 +111,11 @@ class TestRetraction:
|
|||||||
assert issued_date() is None
|
assert issued_date() is None
|
||||||
assert "penalty_cancelled: sick day for 2026-09-22" in caplog.text
|
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:
|
def test_a_sick_penalty_date_alone_does_not_retract(self) -> None:
|
||||||
mark_sick(date(2026, 9, 23))
|
mark_sick(date(2026, 9, 23))
|
||||||
assert not cancel_for_sick_day()
|
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)).ok is False
|
||||||
assert check_lockdown(_ev(phone=phone[:1])).ok is None
|
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:
|
def test_the_report_names_the_day(self) -> None:
|
||||||
mark_sick(TUESDAY)
|
mark_sick(TUESDAY)
|
||||||
with (
|
with (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user