From 1a305c0627fccd31a120d4c74ff7ca5f031060f3 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 16 Oct 2026 15:18:49 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp --- .pre-commit-config.yaml | 4 +- AGENTS.md | 5 +- README.md | 7 +++ pyproject.toml | 2 +- requirements.txt | 2 +- screen_locker/_auto_upgrade.py | 11 ++-- screen_locker/_compliance_predicates.py | 9 ++-- screen_locker/_mcp.py | 5 +- screen_locker/_sick.py | 32 ++++++++++++ screen_locker/_status.py | 4 +- screen_locker/_status_data.py | 4 +- screen_locker/_workday_penalty.py | 30 +++++++---- screen_locker/tests/_real_state_fixtures.py | 10 +++- screen_locker/tests/_sick_helpers.py | 20 ++++++-- .../tests/test_compliance_state_part2.py | 16 ++++++ screen_locker/tests/test_grace_floor.py | 8 +++ screen_locker/tests/test_shutdown_target.py | 17 +++++++ screen_locker/tests/test_sick_button.py | 50 +++++++++++++++++++ screen_locker/tests/test_sick_floor.py | 8 +++ screen_locker/tests/test_status_data_part3.py | 16 ++++++ screen_locker/tests/test_workday_penalty.py | 25 ++++++++++ scripts/_preview_sim.py | 9 ++-- 22 files changed, 257 insertions(+), 37 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d48ec1..4471e54 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index a8f10c3..d9033e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 8842632..d24f3f9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index b0b8a46..b012e5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/requirements.txt b/requirements.txt index 74805d5..36414d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/screen_locker/_auto_upgrade.py b/screen_locker/_auto_upgrade.py index f30f3bc..a02a5d6 100644 --- a/screen_locker/_auto_upgrade.py +++ b/screen_locker/_auto_upgrade.py @@ -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(): diff --git a/screen_locker/_compliance_predicates.py b/screen_locker/_compliance_predicates.py index 6d5fa01..51d4e18 100644 --- a/screen_locker/_compliance_predicates.py +++ b/screen_locker/_compliance_predicates.py @@ -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) diff --git a/screen_locker/_mcp.py b/screen_locker/_mcp.py index 006e7cd..fbefd28 100644 --- a/screen_locker/_mcp.py +++ b/screen_locker/_mcp.py @@ -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), diff --git a/screen_locker/_sick.py b/screen_locker/_sick.py index 675446f..f9fc126 100644 --- a/screen_locker/_sick.py +++ b/screen_locker/_sick.py @@ -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. diff --git a/screen_locker/_status.py b/screen_locker/_status.py index 469b4a4..24ebfd5 100644 --- a/screen_locker/_status.py +++ b/screen_locker/_status.py @@ -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 ===") diff --git a/screen_locker/_status_data.py b/screen_locker/_status_data.py index 47a9b12..537f935 100644 --- a/screen_locker/_status_data.py +++ b/screen_locker/_status_data.py @@ -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] = [] diff --git a/screen_locker/_workday_penalty.py b/screen_locker/_workday_penalty.py index 5545446..9adedc7 100644 --- a/screen_locker/_workday_penalty.py +++ b/screen_locker/_workday_penalty.py @@ -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) diff --git a/screen_locker/tests/_real_state_fixtures.py b/screen_locker/tests/_real_state_fixtures.py index 80d7c0a..1b70f95 100644 --- a/screen_locker/tests/_real_state_fixtures.py +++ b/screen_locker/tests/_real_state_fixtures.py @@ -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) diff --git a/screen_locker/tests/_sick_helpers.py b/screen_locker/tests/_sick_helpers.py index daa2406..3a69d89 100644 --- a/screen_locker/tests/_sick_helpers.py +++ b/screen_locker/tests/_sick_helpers.py @@ -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 diff --git a/screen_locker/tests/test_compliance_state_part2.py b/screen_locker/tests/test_compliance_state_part2.py index 373b083..7933ffb 100644 --- a/screen_locker/tests/test_compliance_state_part2.py +++ b/screen_locker/tests/test_compliance_state_part2.py @@ -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"}})) diff --git a/screen_locker/tests/test_grace_floor.py b/screen_locker/tests/test_grace_floor.py index 1a02fab..5775923 100644 --- a/screen_locker/tests/test_grace_floor.py +++ b/screen_locker/tests/test_grace_floor.py @@ -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) diff --git a/screen_locker/tests/test_shutdown_target.py b/screen_locker/tests/test_shutdown_target.py index f0f056e..28c44c2 100644 --- a/screen_locker/tests/test_shutdown_target.py +++ b/screen_locker/tests/test_shutdown_target.py @@ -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 diff --git a/screen_locker/tests/test_sick_button.py b/screen_locker/tests/test_sick_button.py index 34a0fdf..fd2dd36 100644 --- a/screen_locker/tests/test_sick_button.py +++ b/screen_locker/tests/test_sick_button.py @@ -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() diff --git a/screen_locker/tests/test_sick_floor.py b/screen_locker/tests/test_sick_floor.py index 098c085..4dd1130 100644 --- a/screen_locker/tests/test_sick_floor.py +++ b/screen_locker/tests/test_sick_floor.py @@ -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)) diff --git a/screen_locker/tests/test_status_data_part3.py b/screen_locker/tests/test_status_data_part3.py index e9ae1a9..8d8ff18 100644 --- a/screen_locker/tests/test_status_data_part3.py +++ b/screen_locker/tests/test_status_data_part3.py @@ -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) diff --git a/screen_locker/tests/test_workday_penalty.py b/screen_locker/tests/test_workday_penalty.py index 0d913b2..2e2d9a0 100644 --- a/screen_locker/tests/test_workday_penalty.py +++ b/screen_locker/tests/test_workday_penalty.py @@ -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 diff --git a/scripts/_preview_sim.py b/scripts/_preview_sim.py index b3d1a1b..fee4417 100644 --- a/scripts/_preview_sim.py +++ b/scripts/_preview_sim.py @@ -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: