Sick days: per-locker keep (sickdays 0.2.0) for the workout lock

The workout lock, its explanation, the status view and the workday penalty's
penalised day now ask _sick.stands_down_workout: a sick day that keeps the
workout locker behaves as a normal day. The penalty's ring day is judged by
the alarm switch. Shutdown (20:00 floor, grace floor, base, target, preview
scripts) keeps is_sick: the floor belongs to the day, not to a locker. The
one-tap button still keeps nothing. Test pool fixture and preview harness now
redirect every sickdays resolve_paths binding, so keep edits never reach the
real pool. Pins bumped to sickdays-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:
Krzysztof Rudnicki 2026-10-16 15:18:49 +02:00
parent 0b0a18e81c
commit 1a305c0627
22 changed files with 257 additions and 37 deletions

View File

@ -138,7 +138,7 @@ repos:
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time
- 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
- mcp>=2,<3
- pydantic
- id: pylint
@ -160,7 +160,7 @@ repos:
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time
- 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
- mcp>=2,<3
- pydantic

View File

@ -57,7 +57,10 @@ constant, not the state file: the file once persisted `base_*_hour`, which
made the code's default dead.
A sick day is a date in the shared `sickdays` pool, read only through
`_sick.py`. The reset resolves it with `earned_time.resolve(..., sick=True)`
`_sick.py`. It may keep lockers: the workout lock asks
`_sick.stands_down_workout` (keep-aware), while everything that only computes
shutdown (this floor, `_grace_floor`, `_shutdown_base`, `_shutdown_target`,
the preview scripts) asks `is_sick` and ignores keep. The reset resolves it with `earned_time.resolve(..., sick=True)`
(shutdown >= 20:00) and saves the lift above the earned sum like the grace
lift; a day marked after the reset is lifted by `_sick_floor` on the next
live pass. Both are floors, never terms: later credits are paid out of the

View File

@ -19,6 +19,13 @@ and is paid out of that lift first, so it lands on the ladder value (e.g.
20:40), never 20:00 plus its whole reward. `install.sh` migrates the old
`sick_history.json` dates into the pool once (`scripts/migrate_sick_history.py`).
A sick day may *keep* lockers (`sickdays add --keep workout`). A kept workout
lock behaves exactly as on a normal day (lock, quota, relaxed-day penalty on
that date); only the 20:00 shutdown floor still applies, since it belongs to
the day, not to a locker. The one-tap button keeps nothing. The missed-alarm
penalty is void on its ring day only if `alarm` is off, and on its penalty day
only if `workout` is off. All of it goes through `_sick.stands_down_workout`.
## Shutdown time
Every day starts at **19:00** from 2026-10-01, 20:00 before it (`base_minutes()` in

View File

@ -11,7 +11,7 @@ dependencies = [
"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",
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time",
"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",
]
[project.scripts]

View File

@ -21,7 +21,7 @@ pytest-xdist==3.8.0
# module imports it itself, so it is declared rather than assumed.
requests==2.34.2
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
types-requests==2.33.0.20261006
# Direct dependency since the Firebase session stream (_session_stream) catches
# urllib3's own errors on the long-lived SSE read; requests pulls it in, but

View File

@ -25,7 +25,7 @@ import sys
from screen_locker._decision_log import LockDecision, record_decision
from screen_locker._decision_reasons import reasons_extra
from screen_locker._morning_session import has_workout_skip_today
from screen_locker._sick import is_sick
from screen_locker._sick import stands_down_workout
from screen_locker._weekly_check import has_weekly_minimum, is_relaxed_day
_logger = logging.getLogger(__name__)
@ -83,8 +83,11 @@ class AutoUpgradeMixin(_ReasonsMixin):
"""
def _is_sick_day_today(self) -> bool:
"""Whether today is a date in the shared sickdays pool."""
return is_sick()
"""Whether the sickdays pool switches the workout lock off today.
A sick day that keeps the workout locker is a normal day here.
"""
return stands_down_workout()
def _check_early_exits(self) -> None:
"""Check startup conditions and exit early when appropriate."""
@ -121,7 +124,7 @@ class AutoUpgradeMixin(_ReasonsMixin):
elif self._is_sick_day_today():
_skip(
"sick_day",
"Today is a sick day (sickdays pool).",
"Today is a sick day (sickdays pool); the workout lock is off.",
**self._other_conditions("sick_day"),
)
elif self.has_logged_today():

View File

@ -23,7 +23,7 @@ from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
from screen_locker._day import today_str
from screen_locker._log_io import load_workout_log
from screen_locker._sick import is_sick
from screen_locker._sick import stands_down_workout
from screen_locker._weekly_check import RELAXED_DAY_SKIP_TYPE
if TYPE_CHECKING:
@ -128,9 +128,10 @@ def is_early_bird_pending(pending_file: Path, *, today: str | None = None) -> bo
def is_sick_day_today(today: date | None = None) -> bool:
"""Return True if ``today`` (default: today) is in the sickdays pool.
"""Return True if the sickdays pool switches the workout lock off that day.
Kept here so callers of the status layer have one predicate module to
import from; the answer itself is ``_sick.is_sick``'s.
import from; the answer itself is ``_sick.stands_down_workout``'s, so a
sick day that keeps the workout locker reads False.
"""
return is_sick(today)
return stands_down_workout(today)

View File

@ -137,8 +137,9 @@ def get_flags() -> dict[str, bool]:
(``~/src/utils/freedays``), so a true here also means diet-guard, wake-alarm,
leetcode-guard and home-guard are standing down today.
``is_sick_day_today`` reads the shared ``sickdays`` pool (the same fleet-wide
answer every gate stands down on), never a screen-locker file.
``is_sick_day_today`` reads the shared ``sickdays`` pool and answers "is the
workout lock switched off today": a sick day that keeps the ``workout``
locker reads False. Never a screen-locker file.
"""
return {
"has_logged_today": has_logged_today(_LOG_FILE),

View File

@ -7,6 +7,15 @@ them and tests have one seam.
Reading never raises: an unreadable pool is logged by ``sickdays`` and reads
as *not sick* -- fail closed, the locks apply.
Two questions, deliberately not one: a sick day may *keep* some lockers active
(``sickdays`` 0.2.0), so
* :func:`stands_down_workout` -- "is the workout lock off?" -- is sick AND the
workout locker not kept. The lock ladder, its explanation, the status view
and the missed-alarm penalty's penalised day ask this.
* :func:`is_sick` -- "is the day sick at all?" -- feeds only the 20:00
shutdown floor, which applies on every sick day whatever it keeps.
"""
from __future__ import annotations
@ -29,11 +38,34 @@ def is_sick(day: date | None = None) -> bool:
return sickdays.is_sick_day(day)
def stands_down(locker: str, day: date | None = None) -> bool:
"""Whether ``locker`` (a ``sickdays.LOCKER_*``) is off on ``day``.
For the few consumers that judge a date by a locker other than the workout
one (the missed-alarm penalty's ring day is the alarm switch's).
"""
return sickdays.stands_down(locker, day)
def stands_down_workout(day: date | None = None) -> bool:
"""Whether the workout lock is off on ``day`` (default: today).
True iff the day is sick and does not keep ``sickdays.LOCKER_WORKOUT``.
A kept workout lock behaves exactly as on a normal day.
"""
return stands_down(sickdays.LOCKER_WORKOUT, day)
def sick_dates() -> frozenset[date]:
"""Every date the pool marks sick, past and future."""
return sickdays.sick_days()
def workout_standdown_dates() -> frozenset[date]:
"""Every sick date on which the workout lock is off (keep-aware)."""
return frozenset(d for d in sickdays.sick_days() if stands_down_workout(d))
def push_in_background() -> threading.Thread:
"""Start one best-effort ``sickdays.sync_quietly`` on a background thread.

View File

@ -16,7 +16,7 @@ from screen_locker._extra_benefits import (
has_extended_early_bird,
)
from screen_locker._log_io import load_workout_log
from screen_locker._sick import sick_dates
from screen_locker._sick import workout_standdown_dates
from screen_locker._weekly_check import (
COUNTED_WORKOUT_TYPES,
WEEKLY_WORKOUT_MINIMUM,
@ -77,7 +77,7 @@ def run_status(locker: ScreenLocker) -> None:
monday = today - timedelta(days=today.weekday())
log_file: Path = locker.log_file
log_data = load_workout_log(log_file)
sick_days = sick_dates()
sick_days = workout_standdown_dates()
print("=== Weekly Workout Status ===")

View File

@ -51,7 +51,7 @@ from screen_locker._extra_benefits import (
)
from screen_locker._log_io import load_workout_log
from screen_locker._morning_session import has_workout_skip_today
from screen_locker._sick import sick_dates
from screen_locker._sick import workout_standdown_dates
from screen_locker._weekly_check import (
COUNTED_WORKOUT_TYPES,
WEEKLY_WORKOUT_MINIMUM,
@ -102,7 +102,7 @@ def _day_status(
def _week_days(log_file: Path, *, today_local: datetime) -> tuple[DayStatus, ...]:
"""Return this ISO week's days from Monday through today."""
log_data = load_workout_log(log_file)
sick_days = sick_dates()
sick_days = workout_standdown_dates()
today_date = today_local.date()
monday = today_date - timedelta(days=today_date.weekday())
days: list[DayStatus] = []

View File

@ -8,9 +8,13 @@ here is *HMAC ok, dated today*. Anything else -- missing file, stale date,
bad signature -- is "no penalty", i.e. today's ordinary relaxed-day rule.
The file is void when EITHER its ``issued_date`` (the day of the missed
ring) OR its ``penalty_date`` is a sick day (``sickdays``): a sick day stands
every gate down, and wake-alarm's own retraction of the file can lag its
sync by ~15 min, so it is not relied on here.
ring) belongs to a sick day whose ``alarm`` switch is off, OR its
``penalty_date`` belongs to a sick day whose ``workout`` lock is off
(``sickdays``). Each date is judged by what it owns: the ring is the alarm's,
and the penalty it costs -- the relaxed-day leniency -- is the workout lock's.
A sick day that keeps that locker leaves its half of the penalty in force.
Wake-alarm's own retraction of the file can lag its sync by ~15 min, so it
is not relied on here.
"""
from __future__ import annotations
@ -21,10 +25,11 @@ import logging
from typing import TYPE_CHECKING
from gatelock.log_integrity import verify_entry_hmac
import sickdays
from screen_locker._constants import WORKDAY_PENALTY_FILE
from screen_locker._day import today_str
from screen_locker._sick import is_sick
from screen_locker._sick import stands_down
if TYPE_CHECKING:
from datetime import datetime
@ -53,15 +58,20 @@ def workday_penalty_today(now: datetime | None = None) -> bool:
entry = _read_verified()
if entry is None or entry.get("penalty_date") != today_str(now):
return False
for field in ("issued_date", "penalty_date"):
if _is_sick_iso(entry.get(field)):
_logger.warning("Workday penalty void: its %s is a sick day", field)
for field, locker in (
("issued_date", sickdays.LOCKER_ALARM),
("penalty_date", sickdays.LOCKER_WORKOUT),
):
if _stands_down_iso(entry.get(field), locker):
_logger.warning(
"Workday penalty void: its %s is a sick day with %s off", field, locker
)
return False
return True
def _is_sick_iso(value: object) -> bool:
"""Whether ``value`` is a ``YYYY-MM-DD`` that the sickdays pool marks sick."""
def _stands_down_iso(value: object, locker: str) -> bool:
"""Whether ``value`` is a ``YYYY-MM-DD`` on which ``locker`` is switched off."""
if value is None:
return False # an older file without issued_date: penalty_date rules
try:
@ -69,4 +79,4 @@ def _is_sick_iso(value: object) -> bool:
except ValueError:
_logger.warning("Workday penalty: %r is not a date; not a sick day", value)
return False
return is_sick(day)
return stands_down(locker, day)

View File

@ -18,6 +18,7 @@ from __future__ import annotations
from datetime import date
from pathlib import Path
import sys
from typing import TYPE_CHECKING
from unittest.mock import patch
@ -62,10 +63,15 @@ def _no_sick_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
background push never reaches Firebase.
"""
import sickdays
import sickdays._api
pool = sickdays.Paths.under(tmp_path / "sickdays")
monkeypatch.setattr(sickdays._api, "resolve_paths", lambda paths: paths or pool)
# Every sickdays module that bound ``resolve_paths`` by name: the readers
# (``_api``, which ``stands_down`` / ``kept_lockers`` load through) and the
# writers (``set_kept_lockers`` lives in ``_keep_api``). Patching only
# ``_api`` once let a keep edit land in the developer's real pool.
for name, module in list(sys.modules.items()):
if name.startswith("sickdays.") and hasattr(module, "resolve_paths"):
monkeypatch.setattr(module, "resolve_paths", lambda paths: paths or pool)
monkeypatch.setattr(sickdays, "sync_quietly", lambda **_kw: False)

View File

@ -8,17 +8,31 @@ that accepts any date, so a test can mark a fixed past day as well as today.
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
import sickdays
if TYPE_CHECKING:
from collections.abc import Iterable
def mark_sick(day: date | str | None = None) -> date:
"""Mark ``day`` (default: today, local) sick and return it."""
def mark_sick(day: date | str | None = None, *, keep: Iterable[str] = ()) -> date:
"""Mark ``day`` (default: today, local) sick and return it.
``keep`` names the lockers (``sickdays.LOCKER_*``) the day keeps active;
the default keeps none, i.e. every locker stands down, like the lock
screen's one-tap button.
"""
if day is None:
target = sickdays.today()
elif isinstance(day, str):
target = date.fromisoformat(day)
else:
target = day
sickdays.import_legacy([target])
if keep:
# ``now=target`` makes a past day "today", so a kept past day can be
# written; import_legacy cannot carry a keep set.
sickdays.add_sick_day(target, keep=keep, is_free=lambda _day: False, now=target)
else:
sickdays.import_legacy([target])
return target

View File

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
from unittest.mock import patch
import freedays
import sickdays
from screen_locker._compliance_state import explain_lock_decision
from screen_locker.tests._sick_helpers import mark_sick
@ -100,6 +101,21 @@ class TestExplainLockDecision:
assert result.fired is False
assert result.stage == "sick_day"
def test_a_sick_day_that_keeps_the_workout_lock_does_not_skip(
self, tmp_path: Path
) -> None:
files = self._files(tmp_path)
mark_sick(keep=[sickdays.LOCKER_WORKOUT])
result = explain_lock_decision(
**files,
weekly_minimum_met=False,
relaxed_day=False,
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
)
assert result.stage == "would_lock"
sick_step = next(t for t in result.trace if t.name == "sick_day")
assert sick_step.fired is False
def test_already_logged_skips(self, tmp_path: Path) -> None:
files = self._files(tmp_path)
files["log_file"].write_text(json.dumps({_today(): {"hmac": "sig"}}))

View File

@ -11,6 +11,7 @@ from unittest.mock import patch
import earned_time
import pytest
import sickdays
from screen_locker import _earned, _grace_floor
from screen_locker._grace_floor import (
@ -193,6 +194,13 @@ class TestApply:
assert "failed to write" in caplog.text
assert plan_absorb(0) == (0, 0)
def test_a_sick_day_that_keeps_the_workout_lock_still_has_no_floor(self) -> None:
"""The sick floor is shutdown's, so keep does not bring the grace floor back."""
mark_sick(_DAY, keep=[sickdays.LOCKER_WORKOUT])
host = Host((18 * 60, 18 * 60, 300))
assert host._apply_grace_floor(None, first_done=at("18:55")) is False
assert host.config == (18 * 60, 18 * 60, 300)
def test_a_sick_day_has_no_floor(self) -> None:
"""A sick day has the 20:00 sick floor instead."""
mark_sick(_DAY)

View File

@ -8,15 +8,19 @@ from typing import TYPE_CHECKING
from unittest.mock import patch
import earned_time
import sickdays
from screen_locker import _grace_floor
from screen_locker._shutdown_base import base_minutes
from screen_locker._shutdown_target import (
DayInputs,
day_credit_count,
derive,
gather,
gather_inputs,
workout_units,
)
from screen_locker.tests._sick_helpers import mark_sick
if TYPE_CHECKING:
from pathlib import Path
@ -67,6 +71,19 @@ class TestTarget:
assert target.minutes > SICK_FLOOR
assert target.lift == 0
def test_gather_keeps_the_sick_floor_when_the_workout_lock_is_kept(self) -> None:
"""Rule 2: the 20:00 floor is the day's, whatever the keep set says."""
mark_sick(_DAY, keep=[sickdays.LOCKER_WORKOUT])
assert gather_inputs(_DAY, None).sick_day is True
def test_base_minutes_keeps_the_sick_floor_when_the_workout_lock_is_kept(
self,
) -> None:
plain = base_minutes(_DAY)
assert plain < SICK_FLOOR # reading's cut is in force on this date
mark_sick(_DAY, keep=[sickdays.LOCKER_WORKOUT])
assert base_minutes(_DAY) == SICK_FLOOR
def test_not_sick_is_the_plain_sum(self) -> None:
target = derive(DayInputs(_DAY, _FLAT))
assert target.resolution.sick is False

View File

@ -11,6 +11,8 @@ import freedays
import sickdays
from screen_locker import _sick, _ui_flows_sick
from screen_locker._auto_upgrade import AutoUpgradeMixin
from screen_locker._compliance_predicates import is_sick_day_today
from screen_locker.tests._locker_factories import create_locker_relaxed_day
from screen_locker.tests._sick_helpers import mark_sick
from screen_locker.tests.conftest import create_locker
@ -36,11 +38,59 @@ class TestSeam:
assert _sick.is_sick(day - timedelta(days=1)) is False
assert _sick.sick_dates() >= {day, old}
def test_stands_down_workout_follows_the_keep_set(self) -> None:
day = mark_sick(keep=[sickdays.LOCKER_WORKOUT])
other = mark_sick(day + timedelta(days=1), keep=[sickdays.LOCKER_LEETCODE])
assert _sick.stands_down_workout(day) is False
assert _sick.stands_down_workout(other) is True
assert _sick.stands_down_workout(other + timedelta(days=1)) is False
# The shutdown floor ignores keep: the day is sick either way.
assert _sick.is_sick(day) is True
def test_stands_down_workout_defaults_to_today(self) -> None:
assert _sick.stands_down_workout() is False
mark_sick()
assert _sick.stands_down_workout() is True
def test_workout_standdown_dates_drop_the_kept_days(self) -> None:
kept = mark_sick("2026-06-21", keep=[sickdays.LOCKER_WORKOUT])
off = mark_sick("2026-07-24")
assert _sick.workout_standdown_dates() >= {off}
assert kept not in _sick.workout_standdown_dates()
assert _sick.sick_dates() >= {kept, off}
def test_the_startup_ladder_asks_the_workout_switch(self) -> None:
"""``_is_sick_day_today`` is what the lock skip reads: keep-aware."""
mark_sick(keep=[sickdays.LOCKER_WORKOUT])
assert AutoUpgradeMixin._is_sick_day_today(MagicMock()) is False
sickdays.set_kept_lockers(sickdays.today(), [sickdays.LOCKER_BOOK])
assert AutoUpgradeMixin._is_sick_day_today(MagicMock()) is True
def test_the_pool_fixture_covers_keep_edits(self) -> None:
"""``set_kept_lockers`` must land in the tmp pool, never the real one."""
day = mark_sick()
sickdays.set_kept_lockers(day, [sickdays.LOCKER_DIET])
assert sickdays.kept_lockers(day) == {sickdays.LOCKER_DIET}
def test_the_status_predicate_follows_the_workout_switch(self) -> None:
mark_sick(keep=[sickdays.LOCKER_WORKOUT])
assert is_sick_day_today() is False
sickdays.set_kept_lockers(sickdays.today(), [sickdays.LOCKER_LEETCODE])
assert is_sick_day_today() is True
def test_mark_today_sick(self) -> None:
result = _sick.mark_today_sick()
assert result.ok is True
assert sickdays.is_sick_day() is True
def test_the_one_tap_button_keeps_nothing(self) -> None:
"""The lock screen's "I'm sick" is ALL-OFF: every locker stands down."""
assert _sick.mark_today_sick().ok is True
assert sickdays.kept_lockers() == frozenset()
for locker in sickdays.LOCKERS:
assert sickdays.stands_down(locker) is True
assert _sick.stands_down_workout() is True
def test_already_sick_is_success(self) -> None:
mark_sick()
result = _sick.mark_today_sick()

View File

@ -14,6 +14,7 @@ from unittest.mock import patch
import earned_time
import pytest
import sickdays
from screen_locker import _grace_floor, _shutdown, _sick_floor
from screen_locker._earned import first_minutes
@ -65,6 +66,13 @@ class TestApplySickFloor:
assert host._apply_sick_floor() is False
assert host.config == (18 * 60, 18 * 60, 300)
def test_a_kept_workout_lock_does_not_lift_the_floor_away(self) -> None:
"""The 20:00 floor is the day's, not the workout lock's: keep is moot."""
mark_sick(_DAY, keep=[sickdays.LOCKER_WORKOUT])
host = Host((18 * 60, 18 * 60 + 30, 300))
assert host._apply_sick_floor() is True
assert host.config == (_FLOOR, _FLOOR, 300)
def test_marked_sick_lifts_to_20_00_and_records_the_lift(self) -> None:
mark_sick(_DAY)
host = Host((18 * 60, 18 * 60 + 30, 300))

View File

@ -13,6 +13,7 @@ from typing import TYPE_CHECKING
from unittest.mock import patch
import freedays
import sickdays
from screen_locker._status_data import format_summary_line, gather_status
from screen_locker.tests._sick_helpers import mark_sick
@ -46,6 +47,21 @@ class TestGatherStatusLockSuppression:
assert snap.lock_explanation.fired is False
assert snap.lock_explanation.stage == "sick_day"
def test_sick_day_keeping_the_workout_lock_still_locks(
self, tmp_path: Path
) -> None:
"""A kept workout lock is a normal day: not marked sick, not skipped."""
files = _files(tmp_path)
mark_sick("2024-01-05", keep=[sickdays.LOCKER_WORKOUT])
with patch(
"screen_locker._status_data.has_workout_skip_today", return_value=False
):
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
assert snap.today.is_sick_day is False
assert not format_summary_line(snap).endswith("· sick day")
assert snap.lock_explanation.stage != "sick_day"
def test_wake_alarm_skip_stops_lock(self, tmp_path: Path) -> None:
"""Wake alarm skip stops lock."""
files = _files(tmp_path)

View File

@ -9,6 +9,7 @@ from unittest.mock import patch
from gatelock.log_integrity import compute_entry_hmac
import pytest
import sickdays
from screen_locker import _workday_penalty
from screen_locker._workday_penalty import workday_penalty_today
@ -87,3 +88,27 @@ class TestSickDayVoidsThePenalty:
_write({"penalty_date": "2026-09-23", "issued_date": "yesterday"})
assert workday_penalty_today(NOW) is True
assert "not a date" in caplog.text
class TestKeptLockersDecideThePenalty:
"""Ring date -> the alarm switch; penalty date -> the workout lock."""
def test_a_kept_alarm_on_the_ring_day_keeps_the_penalty(self) -> None:
mark_sick("2026-09-22", keep=[sickdays.LOCKER_ALARM])
_write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"})
assert workday_penalty_today(NOW) is True
def test_a_kept_workout_on_the_ring_day_still_voids_it(self) -> None:
mark_sick("2026-09-22", keep=[sickdays.LOCKER_WORKOUT])
_write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"})
assert workday_penalty_today(NOW) is False
def test_a_kept_workout_on_the_penalty_day_keeps_it(self) -> None:
mark_sick("2026-09-23", keep=[sickdays.LOCKER_WORKOUT])
_write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"})
assert workday_penalty_today(NOW) is True
def test_a_kept_alarm_on_the_penalty_day_still_voids_it(self) -> None:
mark_sick("2026-09-23", keep=[sickdays.LOCKER_ALARM])
_write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"})
assert workday_penalty_today(NOW) is False

View File

@ -8,7 +8,7 @@ adb, the sudo-written config, the sickdays pool) point at a temporary place.
from __future__ import annotations
from datetime import date, datetime, time
import importlib
import sys
from typing import TYPE_CHECKING
import earned_time
@ -114,8 +114,11 @@ SICK_STEP = "sick"
def use_temp_pool(tmp: Path) -> None:
"""Point every sickdays read and write at a pool under ``tmp``."""
pool = sickdays.Paths.under(tmp / "sickdays")
api = importlib.import_module("sickdays._api")
vars(api)["resolve_paths"] = lambda paths: paths or pool
# Every sickdays module that bound ``resolve_paths`` by name: ``_api`` (the
# reads, ``stands_down`` included) and the writers (``_keep_api``, ...).
for name, module in tuple(sys.modules.items()):
if name.startswith("sickdays.") and "resolve_paths" in vars(module):
vars(module)["resolve_paths"] = lambda paths: paths or pool
def sick_pool(day: date, *, sick_day: bool) -> bool: