diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d054ae..f2998f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,7 +62,7 @@ repos: files: ^(screen_locker/.*\.py|requirements\.txt)$ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.17.0 + rev: v0.17.1 hooks: - id: ruff args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes] @@ -134,8 +134,9 @@ repos: # than merely an old one. - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync - - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.10.0#subdirectory=earned_time - - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.1#subdirectory=freedays + - 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 - mcp>=2,<3 - pydantic - id: pylint @@ -155,8 +156,9 @@ repos: # than merely an old one. - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync - - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.10.0#subdirectory=earned_time - - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.1#subdirectory=freedays + - 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 - mcp>=2,<3 - pydantic diff --git a/AGENTS.md b/AGENTS.md index 60c1a56..a8f10c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,13 @@ processes can run them at once. The base is the registry 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)` +(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 +lift first (`_grace_floor.plan_absorb`), so nothing stacks on 20:00. + Flat earners are read from their gate's ledger by `earned_time.done_today` via `_earned.py`: HMAC-verified `credit` rows matched by the earner's own rule (LeetCode's `submitted_at`, book-guard's `detail.bonus == "1"` + diff --git a/README.md b/README.md index 5d046b8..8842632 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,23 @@ # screen-locker -Tkinter/systemd screen locker with workout tracking, sick-day management, and +Tkinter/systemd screen locker with workout tracking, shared sick days, and wake-alarm integration. It enforces a workout cadence by locking the screen -until a RunnerUp-verified (or explicitly justified) workout is logged. +until a verified (RunnerUp, StrongLifts) or budgeted manual workout is logged. + +## Sick days + +A sick day is a date in the fleet-wide [`sickdays`](https://github.com/kuhyx/utils/tree/main/sickdays) +pool, nothing else: no budget, countdown, justification or debt. The lock +screen's **I'm sick** button (retry screen, no-phone screen and the Tue-Thu +prompt) marks today with one tap and unlocks; `sickdays add` or the phone do +the same. On a sick day the workout lock and the grace floor stand down, +wake-alarm's workday penalty is void, and shutdown is +`max(20:00, the normal ladder)` capped at 23:00 (`earned_time.resolve(..., +sick=True)`). A day marked after its daily reset is lifted to 20:00 by the +next 5-minute pass (`_sick_floor`); a workout that day still earns normally +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`). ## Shutdown time diff --git a/install.sh b/install.sh index f8ad554..dbdde79 100755 --- a/install.sh +++ b/install.sh @@ -138,6 +138,16 @@ if [[ -d "$GATELOCK_DIR" ]]; then fi echo " ok: screen_locker and gatelock both import under $PROD_PYTHON" +# One-shot, idempotent: the old sick_history.json's dates move into the shared +# sickdays pool and the file is removed once they read back. Rerunning (or a +# machine that never had the file) is a no-op. A failure keeps the file and +# is retried by the next install; it does not block the locker itself. +echo "Migrating the legacy sick history into the sickdays pool..." +if ! "$PROD_PYTHON" "$REPO_DIR/scripts/migrate_sick_history.py"; then + echo " WARNING: sick-history migration failed (file kept); rerun" >&2 + echo " $REPO_DIR/scripts/migrate_sick_history.py" >&2 +fi + echo echo "=== Done ===" echo "The locker is live. Check when it next fires with:" diff --git a/pyproject.toml b/pyproject.toml index 30de4a8..b0b8a46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "screen-locker" version = "1.0.0" -description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration" +description = "Tkinter/systemd screen locker with workout tracking, shared sick days, and wake-alarm integration" requires-python = ">=3.14" # gatelock: shared lock-window/HMAC backend, also used by diet_guard and wake_alarm # crdt-sync: shared CRDT merge scheme + sync transports, also used by @@ -9,8 +9,9 @@ requires-python = ">=3.14" dependencies = [ "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.3.1#subdirectory=freedays", - "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.10.0#subdirectory=earned_time", + "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", ] [project.scripts] @@ -106,7 +107,7 @@ unfixable = [] # one there would contradict _recovery.py's monotonicity proof that no # transition ever decreases the grab. "screen_locker/_workout_handoff.py" = ["SLF001"] -# explain_lock_decision takes one arg per real file path + sick history, and +# explain_lock_decision takes one arg per real file path and input, and # returns early per lock-decision-chain stage (see module docstring) — both # are the deliberate design, not something to collapse into a config object. "screen_locker/_compliance_state.py" = ["C901", "PLR0911", "PLR0913"] diff --git a/requirements.txt b/requirements.txt index d2d9d61..74805d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,12 +4,12 @@ bandit==1.9.4 codespell==2.4.3 coverage==7.16.2 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.10.0#subdirectory=earned_time -freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.1#subdirectory=freedays +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 gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock mcp==2.3.0 mypy==2.4.0 -pre-commit==4.6.2 +pre-commit==4.7.0 pylint==4.1.3 pytest==9.1.1 pytest-cov==7.1.0 @@ -20,7 +20,8 @@ pytest-xdist==3.8.0 # token over the REST endpoint; crdt-sync already pulls requests in, but this # module imports it itself, so it is declared rather than assumed. requests==2.34.2 -ruff==0.17.0 +ruff==0.17.1 +sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.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 f2fadcd..f30f3bc 100644 --- a/screen_locker/_auto_upgrade.py +++ b/screen_locker/_auto_upgrade.py @@ -1,9 +1,13 @@ -"""Mixin: auto-upgrade early_bird/sick_day pending states via phone or RunnerUp. +"""Mixin: the today-state rungs of the lock ladder, and the early-bird upgrade. -Neither early_bird (a same-day pending marker, see ``_early_bird.py``) nor -sick_day (tracked in ``sick_history.json`` via ``_sick_tracker.py``) live in -log.json — this module only checks their pending state and, on -success, writes the *real* outcome (phone_verified/runnerup_verified) there. +early_bird is a same-day pending marker (``_early_bird.py``), not a log.json +entry — this module only checks its pending state and, on success, writes +the *real* outcome (phone_verified/runnerup_verified) there. + +A sick day is a date in the shared ``sickdays`` pool (``_sick.py``): the +lock stands down for it and nothing is written. A workout done on a sick +day still reaches the log through the ordinary sync paths and earns +normally, so there is no sick-day upgrade any more. The upgrade credits through ``_apply_workout_credit`` -- save first, reward only if the entry was actually appended. It used to push the shutdown hour @@ -18,10 +22,10 @@ from __future__ import annotations import logging import sys -from screen_locker import _sick_tracker 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._weekly_check import has_weekly_minimum, is_relaxed_day _logger = logging.getLogger(__name__) @@ -79,15 +83,11 @@ class AutoUpgradeMixin(_ReasonsMixin): """ def _is_sick_day_today(self) -> bool: - """Check if today is marked as a sick day in sick_history.json.""" - return _sick_tracker.is_sick_day(_sick_tracker.load_history()) + """Whether today is a date in the shared sickdays pool.""" + return is_sick() - def _check_early_exits(self, *, verify_only: bool) -> None: + def _check_early_exits(self) -> None: """Check startup conditions and exit early when appropriate.""" - if verify_only: - if not self._is_sick_day_today(): - _skip("no_sick_day_to_verify", "No sick day logged today.") - return self._check_non_verify_exits() def _check_today_state_exits(self) -> bool: @@ -119,17 +119,11 @@ class AutoUpgradeMixin(_ReasonsMixin): **self._other_conditions("early_bird_window_active"), ) elif self._is_sick_day_today(): - if self._try_auto_upgrade_sick_day(): - _skip( - "sick_day_auto_upgraded", - "Auto-upgraded today's sick_day entry to phone_verified.", - ) - else: - _skip( - "sick_day", - "Sick day already logged today.", - **self._other_conditions("sick_day"), - ) + _skip( + "sick_day", + "Today is a sick day (sickdays pool).", + **self._other_conditions("sick_day"), + ) elif self.has_logged_today(): _skip( "workout_logged_today", @@ -152,44 +146,6 @@ class AutoUpgradeMixin(_ReasonsMixin): return False return True - def _try_auto_upgrade_sick_day(self) -> bool: - """Upgrade sick_day entry when phone or RunnerUp detects a valid workout.""" - try: - status, message = self._verify_phone_workout() - except (OSError, RuntimeError) as exc: - _logger.warning( - "Sick-day auto-upgrade could not reach the phone (%s) — today's " - "sick_day entry stays unverified; trying RunnerUp next", - exc, - ) - status, message = "error", str(exc) - if status == "verified": - self.workout_data["type"] = "phone_verified" - self.workout_data["source"] = message - self.workout_data["after_sick_day"] = "true" - self._apply_workout_credit() - return True - _logger.info("Auto-upgrade phone skipped (%s), trying RunnerUp...", status) - try: - runnerup_status, runnerup_msg = self._verify_runnerup_workout() - except (OSError, RuntimeError) as exc: - _logger.warning( - "Sick-day auto-upgrade could not read RunnerUp either (%s) — " - "today stays a sick_day, NOT upgraded to a verified workout", - exc, - ) - return False - if runnerup_status != "verified": - _logger.info( - "Auto-upgrade RunnerUp skipped (%s): %s", runnerup_status, runnerup_msg - ) - return False - self.workout_data["type"] = "runnerup_verified" - self.workout_data["source"] = runnerup_msg - self.workout_data["after_sick_day"] = "true" - self._apply_workout_credit() - return True - def _try_auto_upgrade_early_bird(self) -> bool: """Try phone then RunnerUp to upgrade an early_bird log entry.""" try: diff --git a/screen_locker/_cli.py b/screen_locker/_cli.py index ad3e360..15aa162 100644 --- a/screen_locker/_cli.py +++ b/screen_locker/_cli.py @@ -36,9 +36,7 @@ _BACKFILL_FLAG = "--backfill-workout-ledger" # Subcommands that own every argument after them (each has its own argparse, # which also answers its own --help). _TRAILING_ARG_FLAGS = (_LOG_MANUAL_FLAG, _REST_DAY_FLAG, _BACKFILL_FLAG) -_MODE_FLAGS = frozenset( - {"--status", "--sync-only", "--apply-bonuses", "--production", "--verify-workout"} -) +_MODE_FLAGS = frozenset({"--status", "--sync-only", "--apply-bonuses", "--production"}) _HELP_FLAGS = frozenset({"-h", "--help"}) _EXIT_USAGE = 2 @@ -47,7 +45,6 @@ usage: python3 -m screen_locker.screen_lock [MODE] With no mode: the demo lock screen (Tk). Modes: --production the real lock screen (what workout-locker.service runs) - --verify-workout lock screen in verify-only mode --status print this week's workout status and exit --sync-only headless: pull synced workouts, apply credit, exit --apply-bonuses headless: apply newly earned flat bonuses, exit @@ -153,8 +150,5 @@ def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None: record_no_decision("--apply-bonuses") sys.exit(0) - locker = locker_cls( - demo_mode="--production" not in argv, - verify_only="--verify-workout" in argv, - ) + locker = locker_cls(demo_mode="--production" not in argv) locker.run() diff --git a/screen_locker/_compliance_predicates.py b/screen_locker/_compliance_predicates.py index dc57840..6d5fa01 100644 --- a/screen_locker/_compliance_predicates.py +++ b/screen_locker/_compliance_predicates.py @@ -23,15 +23,13 @@ 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_tracker import is_sick_day as _is_sick_day +from screen_locker._sick import is_sick from screen_locker._weekly_check import RELAXED_DAY_SKIP_TYPE if TYPE_CHECKING: from datetime import date from pathlib import Path - from screen_locker._sick_tracker import SickHistory - _logger = logging.getLogger(__name__) @@ -129,10 +127,10 @@ def is_early_bird_pending(pending_file: Path, *, today: str | None = None) -> bo return False -def is_sick_day_today(history: SickHistory, *, today: str | None = None) -> bool: - """Return True if today is recorded as a sick day. +def is_sick_day_today(today: date | None = None) -> bool: + """Return True if ``today`` (default: today) is in the sickdays pool. - Thin wrapper around ``_sick_tracker.is_sick_day`` kept here so callers of - the status layer have one predicate module to import from. + Kept here so callers of the status layer have one predicate module to + import from; the answer itself is ``_sick.is_sick``'s. """ - return _is_sick_day(history, today=today) + return is_sick(today) diff --git a/screen_locker/_compliance_state.py b/screen_locker/_compliance_state.py index ac46a28..3e0ba56 100644 --- a/screen_locker/_compliance_state.py +++ b/screen_locker/_compliance_state.py @@ -44,7 +44,6 @@ if TYPE_CHECKING: from pathlib import Path from screen_locker._degraded_sources import DegradedSource - from screen_locker._sick_tracker import SickHistory __all__ = [ "AutoUpgradeOpportunity", @@ -64,7 +63,6 @@ def explain_lock_decision( *, log_file: Path, early_bird_pending_file: Path, - sick_history: SickHistory, weekly_minimum_met: bool, relaxed_day: bool, wake_skip: bool = False, @@ -87,7 +85,7 @@ def explain_lock_decision( window_open = wake_skip expired = pending and not window_open early_bird_open_and_pending = pending and window_open - sick_today = is_sick_day_today(sick_history, today=today_str) + sick_today = is_sick_day_today(local_dt.date()) logged = has_logged_today(log_file, today=today_str) relaxed_day_already_skipped = relaxed_day and is_relaxed_day_skipped_today( log_file, today=today_str @@ -96,7 +94,6 @@ def explain_lock_decision( auto_upgrade = describe_auto_upgrade_opportunity( early_bird_pending=pending, early_bird_window_open=window_open, - is_sick_day=sick_today, ) trace: list[PredicateResult] = [] @@ -159,9 +156,9 @@ def explain_lock_decision( result = _check( "sick_day", fired=sick_today, - reason_true="Today is marked as a sick day in sick_history.json.", - reason_false="Today is not marked as a sick day.", - terminal_reason="Sick day logged today — lock skipped.", + reason_true="Today is a sick day in the shared sickdays pool.", + reason_false="Today is not a sick day.", + terminal_reason="Sick day today — lock skipped.", ) if result is not None: return result diff --git a/screen_locker/_compliance_trace.py b/screen_locker/_compliance_trace.py index 9bf439a..995a891 100644 --- a/screen_locker/_compliance_trace.py +++ b/screen_locker/_compliance_trace.py @@ -32,7 +32,7 @@ class AutoUpgradeOpportunity: """Describes whether the real locker would attempt a live auto-upgrade.""" would_attempt: bool - via: str # "early_bird_expired" | "sick_day" | "none" + via: str # "early_bird_expired" | "none" reason: str @@ -63,7 +63,6 @@ def describe_auto_upgrade_opportunity( *, early_bird_pending: bool, early_bird_window_open: bool, - is_sick_day: bool, ) -> AutoUpgradeOpportunity: """Describe what the real ``_try_auto_upgrade_*`` methods would attempt. @@ -81,15 +80,6 @@ def describe_auto_upgrade_opportunity( "falling back to a full lock." ), ) - if is_sick_day: - return AutoUpgradeOpportunity( - would_attempt=True, - via="sick_day", - reason=( - "Sick day logged — the next lock run will try phone/RunnerUp " - "verification to upgrade it to a real workout." - ), - ) return _NO_UPGRADE diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index 5ff27bd..06cfcb7 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -9,7 +9,7 @@ from gatelock.log_integrity import DEFAULT_HMAC_KEY_FILE # Ephemeral runtime state (self-clears on reboot, same directory gatelock's # own Arbiter uses) -- not repo-tracked data, so it does not belong next to -# log.json/sick_history.json below. +# log.json below. INSTANCE_LOCK_FILE = default_runtime_dir() / "screen_locker-instance.lock" # Single-sourced from gatelock so the literal key path can't drift. The key is @@ -22,38 +22,12 @@ HMAC_KEY_FILE = DEFAULT_HMAC_KEY_FILE # The workout ledger (earned_time.WORKOUT.ledger): one signed credit row per # verified RunnerUp TCX credit and per rest day, resolved at call time. WORKOUT_LEDGER_RELATIVE = Path(".local/share/workout_locker/ledger.json") -SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage) PHONE_PENALTY_DELAY_DEMO = 10 PHONE_PENALTY_DELAY_PRODUCTION = 100 # Penalty added to phone-penalty timer when ADB / phone unavailable -# (so unplugging phone does not become an easy escape into sick mode). +# (so unplugging the phone does not become an easy path to the manual log). NO_PHONE_EXTRA_LOCKOUT_SECONDS = 480 # extra 8 minutes on top of base -# Sick day rate-limiting (rolling windows). Once any window is exhausted -# the "I'm sick" button disappears entirely. -SICK_BUDGET_PER_7_DAYS = 1 -SICK_BUDGET_PER_30_DAYS = 3 -SICK_BUDGET_PER_90_DAYS = 10 -# Each sick day in the trailing 30 days doubles the wait countdown. -SICK_LOCKOUT_MULTIPLIER_PER_RECENT = 2 -# Minimum chars in the freeform sick justification. -SICK_JUSTIFICATION_MIN_CHARS = 120 -# How many past sick justifications to show on the dialog (read-only). -SICK_HISTORY_REVIEW_COUNT = 10 -# The sick screen lists past justifications one per line, at most this many -# and each cut to this many characters: six full-length ones pushed the -# screen 7 px past 1366x768, which the screens-fit gate only sees with a real -# history on disk. The review *count* above is for the budget logic, not the -# screen. -SICK_SCREEN_RECENT_LINES = 4 -SICK_JUSTIFICATION_PREVIEW_CHARS = 36 -# Forced read-only delay before SUBMIT enables when a commitment was made. -SICK_COMMITMENT_FORCED_READ_SECONDS = 5 -# Breaking a commitment counts as this many sick budget days. -SICK_COMMITMENT_PENALTY_DAYS = 2 -# How long the commitment prompt stays visible after a workout unlock. -COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15 -# Manual (unverified) workout rate-limiting — looser than sick days since -# activities like table tennis recur more often than illness. Once either +# Manual (unverified) workout rate-limiting. Once either # window is exhausted the "Log Manual Workout" option disappears entirely. MANUAL_WORKOUT_BUDGET_PER_7_DAYS = 2 MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 10 @@ -117,11 +91,6 @@ HEAT_SKIP_CITY: str = "Warsaw" SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf") # Helper script path (relative to this file) ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh" -# State file to track sick day usage and original config values -SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json" -# Persistent sick-day history (rate-limit, debt, commitments, justifications). -# Distinct from SICK_DAY_STATE_FILE which is a one-day shutdown-config snapshot. -SICK_HISTORY_FILE = Path(__file__).resolve().parent / "sick_history.json" # JSON list of ISO date strings ("YYYY-MM-DD") for which the screen lock is skipped. SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json" # State file tracking streak, skip credits, and early-bird extension weeks. diff --git a/screen_locker/_day.py b/screen_locker/_day.py index 41753e4..f5c659c 100644 --- a/screen_locker/_day.py +++ b/screen_locker/_day.py @@ -1,7 +1,7 @@ """The one definition of "what day is it" for every day-keyed file. -``log.json``, ``sick_day_state.json``, ``early_bird_pending.json``, -``wake_state.json`` and the sick history are all keyed by ``YYYY-MM-DD``. +``log.json``, ``early_bird_pending.json`` and ``wake_state.json`` are all +keyed by ``YYYY-MM-DD``. Until 2026-09-13 each writer and reader derived that key on its own, and most of them used ``datetime.now(tz=UTC)`` while the lock window, the phone app, RunnerUp verification and the status window used LOCAL time. In CEST diff --git a/screen_locker/_grace_floor.py b/screen_locker/_grace_floor.py index 8bf060f..e8dd65f 100644 --- a/screen_locker/_grace_floor.py +++ b/screen_locker/_grace_floor.py @@ -34,7 +34,7 @@ from screen_locker._day import today_str from screen_locker._earned import ceiling, first_credit_time, gate_earners, hhmm from screen_locker._log_io import load_workout_log from screen_locker._rest_day import is_rest_day -from screen_locker._sick_tracker import is_sick_day, load_history +from screen_locker._sick import is_sick from screen_locker._weekly_check import credit_key, day_workout_index from screen_locker._workout_ledger import entry_done_at @@ -96,16 +96,6 @@ def first_done_at( return min(on_day, default=None) -def sick_on(day: date) -> bool: - """Whether ``day`` is a sick day, from the sick-day history itself. - - On a sick day the sick-day shutdown wins and there is no grace floor (the - user's call, 2026-10-09): the floor would otherwise lift the sick-day - reduction straight back up. - """ - return is_sick_day(load_history(), today=day.isoformat()) - - def grace_for(first_done: float | None, day: date) -> int | None: """The floor for ``day`` in minutes after midnight, or ``None`` if none.""" if first_done is None: @@ -117,7 +107,8 @@ def grace_for(first_done: float | None, day: date) -> int | None: return min(ceiling(day), done + GRACE_MINUTES) -def _load_lift(state_file: Path, today: str) -> int: +def load_lift(state_file: Path, today: str) -> int: + """Today's recorded floor lift (grace or sick), 0 when none or unreadable.""" if not state_file.exists(): return 0 # no lift recorded yet: nothing to absorb try: @@ -147,7 +138,7 @@ def plan_absorb(minutes: int, state_file: Path | None = None) -> tuple[int, int] Commit the lift with :func:`save_lift` only once the write succeeded. """ - lift = _load_lift(state_file or GRACE_STATE_FILE, today_str()) + lift = load_lift(state_file or GRACE_STATE_FILE, today_str()) return max(0, minutes - lift), max(0, lift - minutes) @@ -177,7 +168,8 @@ class GraceFloorMixin: simulations). Returns True when the config was raised on this call. """ day = date.fromisoformat(today_str()) - if sick_on(day): + if is_sick(day): + # The sick floor (20:00, _sick_floor) stands in for it that day. _logger.info("Grace floor: skipped, today is a sick day") return False if first_done is None: @@ -199,7 +191,7 @@ class GraceFloorMixin: ): _logger.warning("Grace floor: failed to write shutdown config") return False - lift = _load_lift(GRACE_STATE_FILE, today_str()) + floor - current + lift = load_lift(GRACE_STATE_FILE, today_str()) + floor - current save_lift(lift, GRACE_STATE_FILE) _logger.info( "Grace floor: shutdown %s -> %s (first task done + %d min).", diff --git a/screen_locker/_manual_sync.py b/screen_locker/_manual_sync.py index bbe1233..dc649e1 100644 --- a/screen_locker/_manual_sync.py +++ b/screen_locker/_manual_sync.py @@ -5,7 +5,7 @@ from elsewhere) arrives via crdt-sync as a ``kind="manual_workout"`` payload (see :func:`screen_locker._manual_workout.build_sync_payload`). This module turns those synced payloads into ordinary signed ``manual_workout`` entries in ``log.json`` so they count toward the weekly minimum and — via the -optional ``on_ingested`` callback — earn the same shutdown/debt reward a +optional ``on_ingested`` callback — earn the same shutdown reward a live-logged workout would. Trust model: the PC never trusts the phone's derived fields. Every synced @@ -94,7 +94,7 @@ def ingest_manual_records( right after each newly-appended record — ``prior_entries`` being that entry's own date's entries written before it — so the caller (see ``screen_lock.ScreenLocker._ingest_synced_manual_workouts``) can apply the - identical live-workout shutdown/debt reward. This applies regardless of + identical live-workout shutdown reward. This applies regardless of whether ``date`` is today or back-dated: there's only one current shutdown config, so a back-dated sync still pushes it exactly as a live workout would have that day. diff --git a/screen_locker/_manual_workout.py b/screen_locker/_manual_workout.py index aaa9ca7..1ad1071 100644 --- a/screen_locker/_manual_workout.py +++ b/screen_locker/_manual_workout.py @@ -1,7 +1,7 @@ """Manual (unverified) workout rate-limiting, validation, and entry-building. -Pure logic — no Tk imports. Unlike sick days, manual-workout entries live -directly in ``log.json`` (not a separate history file): a manual +Pure logic — no Tk imports. Manual-workout entries live directly in +``log.json`` (not a separate history file): a manual entry is just another ``workout_data`` dict with ``type="manual_workout"``, so ``LogMixin.save_workout_log`` already HMAC-signs it for free and the weekly-minimum/bonus logic already counts it once wired into diff --git a/screen_locker/_manual_workout_widgets.py b/screen_locker/_manual_workout_widgets.py index 0b354d7..e719400 100644 --- a/screen_locker/_manual_workout_widgets.py +++ b/screen_locker/_manual_workout_widgets.py @@ -84,7 +84,7 @@ class ManualWorkoutFormWidgetsMixin: """Add a half-width label+entry cell and return its backing StringVar. Builds its own cell inline (rather than reusing ``_add_label_entry``, - which is pack-based and shared with the sick dialog) so it slots into + which is pack-based) so it slots into the two-column grid. Paste stays disabled via ``disable_paste``. Args: diff --git a/screen_locker/_mcp.py b/screen_locker/_mcp.py index 4f67877..006e7cd 100644 --- a/screen_locker/_mcp.py +++ b/screen_locker/_mcp.py @@ -49,7 +49,6 @@ from screen_locker._compliance_state import ( from screen_locker._constants import ( EARLY_BIRD_PENDING_FILE, ) -from screen_locker._sick_tracker import load_history from screen_locker._status_data import format_summary_line, gather_status from screen_locker._status_projection import compliance_state_word @@ -64,7 +63,7 @@ logger = logging.getLogger(__name__) mcp = MCPServer("screen-locker") -# Every tool here only reads local state: the workout log, the sick history +# Every tool here only reads local state: the workout log, the sickdays pool # and the clock. Saying so lets a client run them without asking -- and the # day one of them stops being read-only, this annotation is the thing that # has to change with it. (See the section banner below: "never mutate" was @@ -92,7 +91,7 @@ def get_status() -> dict[str, Any]: A JSON-friendly projection of ``gather_status()``: today's and this week's workout outcomes, the shutdown-time projection, the lock-decision trace, - and the rolling sick-day / manual-workout budgets. Reads on-disk state + and the rolling manual-workout budget. Reads on-disk state only (no ADB, sudo, or network) and never mutates anything. """ return asdict(gather_status()) @@ -137,13 +136,15 @@ def get_flags() -> dict[str, bool]: ``scheduled_skips.json``: it is now the shared, fleet-wide free-day pool (``~/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. """ - history = load_history() return { "has_logged_today": has_logged_today(_LOG_FILE), "is_scheduled_skip_today": is_scheduled_skip_today(), "is_early_bird_pending": is_early_bird_pending(EARLY_BIRD_PENDING_FILE), - "is_sick_day_today": is_sick_day_today(history), + "is_sick_day_today": is_sick_day_today(), } diff --git a/screen_locker/_poke_credit.py b/screen_locker/_poke_credit.py index f96319a..ecdd7a5 100644 --- a/screen_locker/_poke_credit.py +++ b/screen_locker/_poke_credit.py @@ -5,7 +5,7 @@ The phone POSTs a finished session straight to the PC path a synced session takes -- :func:`ingest_session_records`, which re-validates and dedups under ``log.lock``, then the shared :meth:`~screen_locker._workout_credit.WorkoutCreditMixin._apply_credit_for_written_entry` -(slot-based credit, shutdown push, debt, the "Workout credited" notification). +(slot-based credit, shutdown push, the "Workout credited" notification). Nothing here computes a reward of its own. Each call builds a FRESH headless locker, the same object @@ -24,7 +24,6 @@ from screen_locker._constants import ( EXTRA_BENEFITS_FILE, SHUTDOWN_BASE_FILE, SHUTDOWN_CONFIG_FILE, - SICK_DAY_STATE_FILE, ) from screen_locker._credit_notify import gaming_minutes_today from screen_locker._day import today_str @@ -88,7 +87,6 @@ class PokeLocker(ScreenLocker): reset_to_base_if_new_day( SHUTDOWN_BASE_FILE, self, - sick_day_state_file=SICK_DAY_STATE_FILE, log_file=self.log_file, ) diff --git a/screen_locker/_rest_day.py b/screen_locker/_rest_day.py index 84b4cf6..eeab69a 100644 --- a/screen_locker/_rest_day.py +++ b/screen_locker/_rest_day.py @@ -20,10 +20,10 @@ Rows live in ``rest_days.json`` as ``{"entries": [...]}``, each signed with ledger: editing the file without the key breaks the row, and a broken row earns nothing. -Unlike a sick day -- decided on the day, at the lock screen, behind a -countdown, rate-limited over 7/30/90 days, and paid back as workout debt -- a -rest day is planned, costs no debt, and only touches shutdown time. It does -not stand down the screen lock or the weekly minimum (``freedays`` does that). +Unlike a sick day -- a date in the shared ``sickdays`` pool that stands +every gate down -- a rest day is planned and only touches shutdown time. It +does not stand down the screen lock or the weekly minimum (``freedays`` and +``sickdays`` do that). """ from __future__ import annotations diff --git a/screen_locker/_shutdown.py b/screen_locker/_shutdown.py index 76d7c65..5b5617d 100644 --- a/screen_locker/_shutdown.py +++ b/screen_locker/_shutdown.py @@ -16,7 +16,7 @@ from screen_locker._constants import ( from screen_locker._day import today_str from screen_locker._earned import ceiling, first_minutes, hhmm, span from screen_locker._grace_floor import GraceFloorMixin, plan_absorb, save_lift -from screen_locker._shutdown_sick_state import SickDayStateMixin +from screen_locker._sick_floor import SickFloorMixin if TYPE_CHECKING: from pathlib import Path @@ -29,9 +29,6 @@ _logger = logging.getLogger(__name__) _SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END") _MINUTES_PER_HOUR = 60 -# A sick day moves shutdown an hour earlier, but never before 18:00. -_SICK_DAY_STEP = 60 -_SICK_DAY_FLOOR = 18 * _MINUTES_PER_HOUR # Extra bonuses may name midnight; the helper still clamps them to 23:00. _MIDNIGHT = 24 * _MINUTES_PER_HOUR @@ -70,43 +67,9 @@ def read_shutdown_config(path: Path) -> tuple[int, int, int] | None: return mon_wed, thu_sun, morning_end -class ShutdownMixin(GraceFloorMixin, SickDayStateMixin): +class ShutdownMixin(GraceFloorMixin, SickFloorMixin): """Mixin providing shutdown schedule adjustment functionality.""" - def _apply_earlier_shutdown(self, today: str) -> bool: - """Read config, save state, and write an earlier shutdown time.""" - config_values = self._read_shutdown_config() - if config_values is None: - return False - mon_wed, thu_sun, morning_end = config_values - if not self._save_sick_day_state(today, mon_wed, thu_sun): - _logger.error("Failed to save state - aborting adjustment") - return False - return self._write_shutdown_config( - max(_SICK_DAY_FLOOR, mon_wed - _SICK_DAY_STEP), - max(_SICK_DAY_FLOOR, thu_sun - _SICK_DAY_STEP), - morning_end, - ) - - def _adjust_shutdown_time_earlier(self) -> bool: - """Adjust shutdown schedule an hour earlier (stricter). - - This can only be used once per day. Original values are saved and - automatically restored when checked the next day. - - Returns True if successful, False otherwise. - """ - today = today_str() - self._restore_original_config_if_needed() - if self._sick_mode_used_today(): - _logger.warning("Sick mode already used today") - return False - try: - return self._apply_earlier_shutdown(today) - except (OSError, ValueError) as e: - _logger.warning("Failed to adjust shutdown time: %s", e) - return False - def _adjust_shutdown_time_later(self) -> bool: """Push shutdown later by the first workout's reward, capped at the ceiling. diff --git a/screen_locker/_shutdown_base.py b/screen_locker/_shutdown_base.py index a48ac5a..746044a 100644 --- a/screen_locker/_shutdown_base.py +++ b/screen_locker/_shutdown_base.py @@ -18,8 +18,10 @@ followed -- on 2026-09-13 that left the bar at 21:00 with a counted workout on disk. Any bonus source that is not a term of this derivation has that bug; registering it in ``earned_time`` makes it one. -The reset clears the sick-day state file, so sick-restore cannot overwrite -the fresh base later in the same startup. +A sick day (``sickdays``) is resolved with ``sick=True``: shutdown is at least +20:00, and the lift above the earned sum is saved like the grace lift. A day +marked sick after the reset is lifted by the live pass +(:mod:`screen_locker._sick_floor`). """ from __future__ import annotations @@ -47,6 +49,7 @@ from screen_locker._gate_bonus import apply_counted_bonus, date_stamp, reset_sta from screen_locker._grace_floor import save_lift from screen_locker._log_io import load_workout_log from screen_locker._shutdown_target import gather +from screen_locker._sick import is_sick from screen_locker._weekly_check import count_day_credits if TYPE_CHECKING: @@ -62,14 +65,21 @@ def base_minutes(day: date | None = None) -> int: """The base shutdown for ``day`` (default today), in minutes after midnight. Every penalty in force is already taken off; from today on, a gate's not - before it has paid out (:func:`~screen_locker._earned.first_credits`). + before it has paid out (:func:`~screen_locker._earned.first_credits`). A + sick day's base is raised to the sick floor, as ``earned_time.resolve`` + does for a sick day that earned nothing. """ target = day or datetime.now().astimezone().date() earners = registry(target) starts = first_credits(earners, target) - if starts is None: - return earned_time.base_for(target, earners).shutdown_minutes - return earned_time.base_for(target, earners, first_credits=starts).shutdown_minutes + base = ( + earned_time.base_for(target, earners) + if starts is None + else earned_time.base_for(target, earners, first_credits=starts) + ).shutdown_minutes + if is_sick(target): + return earned_time.sick_shutdown_minutes(base, target) + return base def today_credit_count(log_file: Path, today: str) -> int: @@ -114,21 +124,9 @@ def _save_state(state_file: Path, state: dict[str, Any]) -> None: _logger.warning("Failed to write shutdown reset state: %s", exc) -def _clear_sick_day_state(sick_day_state_file: Path | None) -> None: - """Remove stale sick-day state so it does not override the base reset.""" - if sick_day_state_file is None or not sick_day_state_file.exists(): - return - try: - sick_day_state_file.unlink() - _logger.info("Daily base reset: cleared stale sick-day state.") - except OSError as exc: - _logger.warning("Daily base reset: could not remove sick-day state: %s", exc) - - def reset_to_base_if_new_day( state_file: Path, mixin: object, - sick_day_state_file: Path | None = None, log_file: Path | None = None, ) -> bool: """Reset the shutdown config to the base if a new calendar day began. @@ -136,20 +134,18 @@ def reset_to_base_if_new_day( Writes the base plus whatever today has already earned -- the workout time in *log_file* (see :func:`today_credit_count`) and every flat earner's time -- via *mixin*._write_shutdown_config (with restore=True so - the script allows moving the time earlier), stamps ``last_reset_date`` in - *state_file*, and removes *sick_day_state_file* if it exists so the - sick-restore path does not fight with the fresh base on the same startup. + the script allows moving the time earlier) and stamps ``last_reset_date`` + in *state_file*. Returns True if a reset was performed, False if today was already reset. """ with bonus_lock(state_file): - return _reset(state_file, mixin, sick_day_state_file, log_file) + return _reset(state_file, mixin, log_file) def _reset( state_file: Path, mixin: object, - sick_day_state_file: Path | None, log_file: Path | None, ) -> bool: """:func:`reset_to_base_if_new_day`'s body; the caller holds the lock.""" @@ -170,8 +166,7 @@ def _reset( _logger.warning("Daily base reset: failed to write shutdown config.") return False - _clear_sick_day_state(sick_day_state_file) - # Later additions are paid out of the grace lift first (see _grace_floor). + # Later additions are paid out of the floor lift (grace or sick) first. save_lift(derived.lift) # The bonuses the reset already included are stamped, so the live pass @@ -184,11 +179,12 @@ def _reset( f"{span(t.shutdown_minutes)} {t.earner.label}" for t in day.terms ) _logger.info( - "Daily base reset: %s (base %s + %s already earned today%s%s).", + "Daily base reset: %s (base %s + %s already earned today%s%s%s).", hhmm(target), hhmm(day.base.shutdown_minutes), earned, "; rest day" if derived.rest_day else "", + "; sick day" if day.sick else "", f"; grace floor {hhmm(derived.grace)}" if derived.grace is not None else "", ) return True @@ -246,5 +242,7 @@ def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None: state = _load_state(state_file) if apply_counted_bonus(state, adjust, earner, today_str()): _save_state(state_file, state) - # Last, so the floor compares against everything earned so far. + # Last, so the floors compare against everything earned so far. The + # sick floor covers a day marked sick after today's reset. + mixin._apply_sick_floor() mixin._apply_grace_floor(getattr(mixin, "log_file", None)) diff --git a/screen_locker/_shutdown_sick_state.py b/screen_locker/_shutdown_sick_state.py deleted file mode 100644 index 0dac985..0000000 --- a/screen_locker/_shutdown_sick_state.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Sick-day shutdown state: persistence and next-day restoration. - -Split out of :mod:`screen_locker._shutdown` to keep every file under the -250-line cap. Composed back into ``ShutdownMixin`` there, so callers see no -change. - -Tracks the shutdown times (minutes after midnight) that were in force before -a sick day moved them earlier, so the following day can put them back. A state -file written before the minutes migration holds whole hours under -``original_*_hour`` and is still read. -""" - -from __future__ import annotations - -import json -import logging - -from screen_locker._constants import SICK_DAY_STATE_FILE -from screen_locker._day import today_str - -_logger = logging.getLogger(__name__) - -_MINUTES_PER_HOUR = 60 - - -def _original_minutes(state: dict[str, object], band: str) -> int | None: - """The saved time for *band* (``mon_wed``/``thu_sun``) in minutes, if any.""" - minutes = state.get(f"original_{band}_minutes") - if minutes is not None: - return int(str(minutes)) - hours = state.get(f"original_{band}_hour") - if hours is not None: - return int(str(hours)) * _MINUTES_PER_HOUR - return None - - -class SickDayStateMixin: - """Persists and restores the pre-sick-day shutdown configuration.""" - - def _sick_mode_used_today(self) -> bool: - """Check if sick mode was already used today.""" - if not SICK_DAY_STATE_FILE.exists(): - return False - - try: - with SICK_DAY_STATE_FILE.open() as f: - state = json.load(f) - today = today_str() - return state.get("date") == today - except (OSError, json.JSONDecodeError) as exc: - _logger.warning( - "Could not read sick-day state from %s: %s — treating sick mode " - "as UNUSED today, which may allow a second sick day", - SICK_DAY_STATE_FILE, - exc, - ) - return False - - def _save_sick_day_state( - self, - date: str, - orig_mon_wed: int, - orig_thu_sun: int, - ) -> bool: - """Save sick day state with the original times, in minutes. - - Returns True if saved successfully, False otherwise. - """ - state = { - "date": date, - "original_mon_wed_minutes": orig_mon_wed, - "original_thu_sun_minutes": orig_thu_sun, - } - try: - with SICK_DAY_STATE_FILE.open("w") as f: - json.dump(state, f, indent=2) - except OSError as e: - _logger.warning("Failed to save sick day state: %s", e) - return False - - _logger.info("Saved sick day state for %s", date) - return True - - def _load_sick_day_state(self) -> tuple[str, int, int] | None: - """Load sick day state file. - - Returns (date, orig_mon_wed, orig_thu_sun) in minutes, or None. - """ - with SICK_DAY_STATE_FILE.open() as f: - state = json.load(f) - date = state.get("date") - orig_mw = _original_minutes(state, "mon_wed") - orig_ts = _original_minutes(state, "thu_sun") - if date is None or orig_mw is None or orig_ts is None: - return None - return (str(date), orig_mw, orig_ts) - - def _write_restored_config( - self, - orig_mw: int, - orig_ts: int, - state_date: str, - ) -> None: - """Write restored config values and clean up state file.""" - config_values = self._read_shutdown_config() - if config_values: - _, _, morning_end = config_values - _logger.info( - "Restoring original shutdown config from %s", - state_date, - ) - self._write_shutdown_config( - orig_mw, - orig_ts, - morning_end, - restore=True, - ) - SICK_DAY_STATE_FILE.unlink() - _logger.info("Removed stale sick day state from %s", state_date) - - def _restore_original_config_if_needed(self) -> None: - """Restore original config if sick day state is from a previous day.""" - if not SICK_DAY_STATE_FILE.exists(): - return - try: - loaded = self._load_sick_day_state() - if loaded is None: - return - state_date, orig_mw, orig_ts = loaded - today = today_str() - if state_date != today: - self._write_restored_config(orig_mw, orig_ts, state_date) - except (OSError, json.JSONDecodeError) as e: - _logger.warning("Error checking sick day state: %s", e) diff --git a/screen_locker/_shutdown_target.py b/screen_locker/_shutdown_target.py index 1da3c83..9f3c395 100644 --- a/screen_locker/_shutdown_target.py +++ b/screen_locker/_shutdown_target.py @@ -4,8 +4,9 @@ (:mod:`screen_locker._shutdown_base`) and the read-only preview (``scripts/preview_shutdown.py``), so a dry run exercises the real sum:: - earned = earned_time.resolve(answers) # base + every earner, capped - target = min(ceiling, max(earned, grace floor)) + earned = earned_time.resolve(answers, sick=...) # base + every earner, capped; + # sick: at least 20:00 + target = min(ceiling, max(earned, grace floor)) # no grace floor when sick A declared rest day answers the workout with at least one unit, i.e. its first-unit bonus; a real workout on top of it earns only what a further unit @@ -20,9 +21,10 @@ from typing import TYPE_CHECKING import earned_time from screen_locker._earned import ceiling, first_credits, gate_answers, registry -from screen_locker._grace_floor import first_done_at, grace_for, sick_on +from screen_locker._grace_floor import first_done_at, grace_for from screen_locker._log_io import load_workout_log from screen_locker._rest_day import is_rest_day +from screen_locker._sick import is_sick from screen_locker._weekly_check import count_day_credits if TYPE_CHECKING: @@ -36,7 +38,8 @@ class Target: """One day's derived shutdown and what made it. Attributes: - resolution: ``earned_time``'s sum (base + terms, capped). + resolution: ``earned_time``'s sum (base + terms, capped; on a sick + day raised to the 20:00 sick floor). rest_day: Whether the day is a declared rest day. first_done_at: Unix time the day's first earner was done, if any. grace: The grace floor in minutes, or ``None``. @@ -51,12 +54,16 @@ class Target: @property def earned(self) -> int: - """The earned shutdown, before the grace floor.""" - return self.resolution.shutdown_minutes + """The earned shutdown: base plus terms, before any floor.""" + return self.resolution.shutdown_minutes - self.resolution.sick_lift_minutes @property def lift(self) -> int: - """How far the grace floor lifts the earned shutdown.""" + """How far the floors (grace or sick) lift the earned shutdown. + + Saved as the day's lift (``_grace_floor.save_lift``), so a credit that + lands later is paid out of it first instead of stacking on 20:00. + """ return self.minutes - self.earned @@ -76,7 +83,8 @@ class DayInputs: workout_credits: The log's distinct workout credits. rest_day: Declared rest day. first_done: Unix time the day's first earner was done, if any. - sick_day: Sick day: the sick-day shutdown wins, no grace floor. + sick_day: Sick day (``sickdays``): shutdown is at least 20:00 + (``earned_time.resolve(..., sick=True)``) and no grace floor. first_credits: Each penalised gate's first real credit (:func:`~screen_locker._earned.first_credits`); ``None`` resolves on ``penalty_from`` alone, as before earned_time 0.6. @@ -102,10 +110,11 @@ def derive(inputs: DayInputs) -> Target: # day's registry, so the earners asked and the earners summed match. earners = registry(day) starts = inputs.first_credits + sick = inputs.sick_day resolution = ( - earned_time.resolve(answers, day, earners) + earned_time.resolve(answers, day, earners, sick=sick) if starts is None - else earned_time.resolve(answers, day, earners, first_credits=starts) + else earned_time.resolve(answers, day, earners, first_credits=starts, sick=sick) ) grace = None if inputs.sick_day else grace_for(inputs.first_done, day) minutes = resolution.shutdown_minutes @@ -129,17 +138,20 @@ def day_credit_count(log_file: Path | None, day: date) -> int: return count_day_credits(iso, [e for e in entries if isinstance(e, dict)]) +def gather_inputs(day: date, log_file: Path | None) -> DayInputs: + """Read ``day``'s real inputs: ledgers, log, rest days, sick pool.""" + rest_day = is_rest_day(day) + return DayInputs( + day=day, + flat=gate_answers(day), + workout_credits=day_credit_count(log_file, day), + rest_day=rest_day, + first_done=first_done_at(day, log_file, rest_day=rest_day), + sick_day=is_sick(day), + first_credits=first_credits(registry(day), day), + ) + + def gather(day: date, log_file: Path | None) -> Target: """Derive ``day``'s target from the real ledgers, log and rest days.""" - rest_day = is_rest_day(day) - return derive( - DayInputs( - day=day, - flat=gate_answers(day), - workout_credits=day_credit_count(log_file, day), - rest_day=rest_day, - first_done=first_done_at(day, log_file, rest_day=rest_day), - sick_day=sick_on(day), - first_credits=first_credits(registry(day), day), - ) - ) + return derive(gather_inputs(day, log_file)) diff --git a/screen_locker/_sick.py b/screen_locker/_sick.py new file mode 100644 index 0000000..675446f --- /dev/null +++ b/screen_locker/_sick.py @@ -0,0 +1,82 @@ +"""The one place screen-locker asks the shared ``sickdays`` pool. + +A sick day is a date in that pool, nothing else: no budget, no countdown, no +justification and no debt. Every consumer here (lock ladder, shutdown, +status, MCP) goes through this module, so the answer cannot drift between +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. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import threading +from typing import TYPE_CHECKING + +import sickdays + +if TYPE_CHECKING: + from datetime import date + +_logger = logging.getLogger(__name__) + + +def is_sick(day: date | None = None) -> bool: + """Whether ``day`` (default: today) is a sick day.""" + return sickdays.is_sick_day(day) + + +def sick_dates() -> frozenset[date]: + """Every date the pool marks sick, past and future.""" + return sickdays.sick_days() + + +def push_in_background() -> threading.Thread: + """Start one best-effort ``sickdays.sync_quietly`` on a background thread. + + So the phone sees a lock-screen mark without waiting for the 15-minute + sickdays-sync timer. Not a daemon: the lock window closes at once and the + process lingers only until the push ends, instead of killing it mid-write. + ``sync_quietly`` never raises; a failed push is retried by that timer. + """ + worker = threading.Thread( + target=sickdays.sync_quietly, name="sickdays-push", daemon=False + ) + worker.start() + return worker + + +@dataclass(frozen=True) +class MarkResult: + """Outcome of :func:`mark_today_sick`. + + Attributes: + ok: Today is a sick day now (newly marked or already was). + message: What to show the user: a confirmation, or why it was refused. + """ + + ok: bool + message: str + + +def mark_today_sick() -> MarkResult: + """Mark today sick in the shared pool; every gate stands down for it. + + Already sick counts as success. Any other refusal (a free day, an + unreadable pool, freedays not importable) is returned with sickdays' own + explanation, so the lock screen can show it instead of unlocking. + """ + today = sickdays.today() + try: + sickdays.add_sick_day(today) + except sickdays.AlreadySickError: + _logger.warning("%s was already a sick day; unlocking", today) + return MarkResult(ok=True, message="Today is already a sick day.") + except sickdays.SickDayError as exc: + _logger.warning("Could not mark %s as a sick day: %s", today, exc) + return MarkResult(ok=False, message=str(exc)) + _logger.warning("Marked %s as a sick day from the lock screen", today) + return MarkResult(ok=True, message="Today is a sick day. Get well soon.") diff --git a/screen_locker/_sick_budget.py b/screen_locker/_sick_budget.py deleted file mode 100644 index 1fe4f3e..0000000 --- a/screen_locker/_sick_budget.py +++ /dev/null @@ -1,131 +0,0 @@ -"""The sick-day record and the rolling budget computed from it. - -Split out of :mod:`screen_locker._sick_tracker` to keep every file under the -250-line cap. Re-exported from there, so callers and their patch targets are -unchanged. - -Everything here is pure: it reads a :class:`SickHistory` already in memory. -Loading and saving that history stays in ``_sick_tracker``. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta -import logging -from typing import Any - -from screen_locker._constants import ( - SICK_BUDGET_PER_7_DAYS, - SICK_BUDGET_PER_30_DAYS, - SICK_BUDGET_PER_90_DAYS, - SICK_LOCKOUT_MULTIPLIER_PER_RECENT, - SICK_LOCKOUT_SECONDS, -) -from screen_locker._day import today_str - -_logger = logging.getLogger(__name__) - - -@dataclass -class SickHistory: - """Persistent sick-day bookkeeping.""" - - sick_days: list[str] = field(default_factory=list) - debt: int = 0 - commitments: dict[str, bool] = field(default_factory=dict) - broken_commitments: list[str] = field(default_factory=list) - justifications: list[dict[str, Any]] = field(default_factory=list) - # Sick days later disproved by a workout the locker could not read at the - # time. Kept beside the justifications rather than folded into them: those - # entries are HMAC-signed over the whole dict, so annotating one in place - # would break verification and look like tampering. - revocations: list[dict[str, Any]] = field(default_factory=list) - - -def _today_iso() -> str: - """Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``).""" - return today_str() - - -def _parse_iso(date_str: str) -> datetime | None: - """Parse ``YYYY-MM-DD`` into a UTC datetime, or return None.""" - try: - return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC) - except ValueError as exc: - _logger.warning( - "Sick history holds an unparsable date %r (%s) — that entry is " - "IGNORED when counting sick days", - date_str, - exc, - ) - return None - - -def is_sick_day(history: SickHistory, *, today: str | None = None) -> bool: - """Return True if today is recorded as a sick day.""" - today_str = today or _today_iso() - return today_str in history.sick_days - - -def count_in_window( - history: SickHistory, - days: int, - *, - today: str | None = None, -) -> int: - """Return how many ``sick_days`` fall in the trailing ``days`` window.""" - today_str = today or _today_iso() - today_dt = _parse_iso(today_str) - if today_dt is None: - return 0 - cutoff = today_dt - timedelta(days=days) - count = 0 - for entry in history.sick_days: - parsed = _parse_iso(entry) - if parsed is None: - continue - if cutoff < parsed <= today_dt: - count += 1 - return count - - -def is_budget_exhausted( - history: SickHistory, - *, - today: str | None = None, -) -> bool: - """Return True if any rolling window has reached its sick budget.""" - return ( - count_in_window(history, 7, today=today) >= SICK_BUDGET_PER_7_DAYS - or count_in_window(history, 30, today=today) >= SICK_BUDGET_PER_30_DAYS - or count_in_window(history, 90, today=today) >= SICK_BUDGET_PER_90_DAYS - ) - - -def compute_lockout_seconds( - history: SickHistory, - *, - today: str | None = None, -) -> int: - """Escalating sick countdown: ``base * 2 ** recent_count_in_30d``.""" - recent = count_in_window(history, 30, today=today) - multiplier = SICK_LOCKOUT_MULTIPLIER_PER_RECENT**recent - return SICK_LOCKOUT_SECONDS * multiplier - - -def budget_summary( - history: SickHistory, - *, - today: str | None = None, -) -> str: - """One-line UI summary string for budget + debt.""" - week = count_in_window(history, 7, today=today) - month = count_in_window(history, 30, today=today) - quarter = count_in_window(history, 90, today=today) - return ( - f"Sick: {week}/{SICK_BUDGET_PER_7_DAYS}w · " - f"{month}/{SICK_BUDGET_PER_30_DAYS}m · " - f"{quarter}/{SICK_BUDGET_PER_90_DAYS}q · " - f"Debt: {history.debt}" - ) diff --git a/screen_locker/_sick_commitment.py b/screen_locker/_sick_commitment.py deleted file mode 100644 index 315f23d..0000000 --- a/screen_locker/_sick_commitment.py +++ /dev/null @@ -1,90 +0,0 @@ -"""The next-day commitment prompt shown after a sick day. - -Split out of :mod:`screen_locker._sick_dialog` to keep every file under the -250-line cap. Composed back into ``SickDialogMixin`` there, so callers see no -change. - -Taking a sick day asks for a commitment to train tomorrow; breaking it is -recorded and costs budget, which is what stops sick days being free. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from screen_locker import _sick_tracker -from screen_locker._constants import COMMITMENT_PROMPT_TIMEOUT_SECONDS - -if TYPE_CHECKING: - from collections.abc import Callable - - -class SickCommitmentMixin: - """Asks for, times out, and records the next-day training commitment.""" - - def _show_commitment_prompt(self, *, on_done: Callable[[], None]) -> None: - """Ask the user to commit to working out tomorrow. - - Calls ``on_done()`` once the user answers or the timeout elapses. - """ - self.clear_container() - self._label( - "Commit to working out tomorrow?", - role="display", - color=self._colors.palette.warning, - pad="md", - ) - # ~80 chars at font_size 16 would render as one unbroken line past the - # ~70-char readable range (rule 21) -- wrap it explicitly. - prompt = self._text( - "If you say YES and skip via 'I'm sick' tomorrow, " - "the sick day costs 2x normal.", - role="body", - ) - prompt.config(wraplength=560, justify="center") - self._commitment_done_fn = on_done - self._commitment_remaining = COMMITMENT_PROMPT_TIMEOUT_SECONDS - self._commitment_timer_label = self._text( - f"Auto-skipping in {COMMITMENT_PROMPT_TIMEOUT_SECONDS}s", - color=self._colors.palette.muted, - ) - row = self._button_row() - self._button( - row, - "YES", - bg=self._colors.palette.success, - command=lambda: self._answer_commitment(commit=True), - width=12, - ).pack(side="left", padx=self._colors.space("sm")) - self._button( - row, - "NO", - bg=self._colors.palette.field_bg, - command=lambda: self._answer_commitment(commit=False), - width=12, - ).pack(side="left", padx=self._colors.space("sm")) - self._tick_commitment_timeout() - - def _tick_commitment_timeout(self) -> None: - """Advance commitment auto-skip timer; default to NO when it expires.""" - if self._commitment_remaining <= 0: - self._answer_commitment(commit=False) - return - self._commitment_timer_label.config( - text=f"Auto-skipping in {self._commitment_remaining}s", - ) - self._commitment_remaining -= 1 - self.root.after(1000, self._tick_commitment_timeout) - - def _answer_commitment(self, *, commit: bool) -> None: - """Persist the commitment answer and call the completion callback.""" - # Disable timer re-entry by zeroing remaining. - self._commitment_remaining = -1 - if commit: - history = _sick_tracker.load_history() - _sick_tracker.record_commitment_for_tomorrow(history) - _sick_tracker.save_history(history) - done = getattr(self, "_commitment_done_fn", None) - if done is not None: - self._commitment_done_fn = None - done() diff --git a/screen_locker/_sick_dialog.py b/screen_locker/_sick_dialog.py deleted file mode 100644 index 588ea0d..0000000 --- a/screen_locker/_sick_dialog.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Sick-day justification + commitment dialog mixin for the screen locker.""" - -from __future__ import annotations - -import logging -import tkinter as tk -from typing import TYPE_CHECKING - -from gatelock import escape_text_tab_trap - -from screen_locker import _sick_tracker -from screen_locker._constants import ( - SICK_COMMITMENT_FORCED_READ_SECONDS, - SICK_JUSTIFICATION_MIN_CHARS, - SICK_SCREEN_RECENT_LINES, -) -from screen_locker._sick_commitment import SickCommitmentMixin -from screen_locker._surface_group import FrameGroup, TextGroup -from screen_locker._ui_widgets import disable_paste as _disable_paste - -if TYPE_CHECKING: - from screen_locker._sick_tracker import SickHistory - -_logger = logging.getLogger(__name__) - - -class SickDialogMixin(SickCommitmentMixin): - """Renders the sick-day justification screen and commitment prompts.""" - - # ------------------------------------------------------------------ - # Sick-day justification dialog - # ------------------------------------------------------------------ - - def _show_sick_justification(self) -> None: - """Render the structured sick-day justification screen.""" - history = _sick_tracker.load_history() - self._sick_history_cache: SickHistory = history - self.clear_container() - self._label("Sick Day Request", color=self._colors.palette.warning, pad="sm") - self._text( - _sick_tracker.budget_summary(history), color=self._colors.palette.warning - ) - - recent = _sick_tracker.format_recent_justifications( - history, n=SICK_SCREEN_RECENT_LINES - ) - if recent: - self._text( - "Recent sick days:", - role="label", - color=self._colors.palette.muted, - pad="sm", - ) - self._text(recent, role="label", color=self._colors.palette.muted, pad="sm") - - had_commitment = _sick_tracker.had_commitment_for_today(history) - if had_commitment: - self._text( - "⚠ Yesterday you committed to working out today.", - role="body", - color=self._colors.palette.danger, - ) - self._text( - "Breaking the commitment costs 2 sick-budget days.", - role="label", - color=self._colors.palette.danger, - ) - - self._build_justification_form(had_commitment=had_commitment) - - def _build_justification_form(self, *, had_commitment: bool) -> None: - """Add justification form fields and submit button to the container.""" - form = self.container.child_frame(bg=self._colors.palette.bg) - form.pack(pady=self._colors.space("sm")) - - self._sick_symptom_var = tk.StringVar() - self._sick_onset_var = tk.StringVar() - self._sick_severity_var = tk.IntVar(value=5) - self._sick_text_widget = self._add_form_widgets(form) - - self._sick_error_label = self._text( - "", color=self._colors.palette.danger, pad="sm" - ) - - button_row = self._button_row() - # Starts disabled during the forced-read delay -- field_bg (our - # neutral/secondary token) signals "not yet actionable" better than - # the accent used for an immediately-clickable submit. - self._sick_submit_button = self._button( - button_row, - "SUBMIT", - bg=self._colors.palette.field_bg, - command=self._submit_sick_justification, - width=12, - ) - self._sick_submit_button.pack(side="left", padx=self._colors.space("sm")) - self._button( - button_row, - "BACK", - bg=self._colors.palette.field_bg, - command=self._start_phone_check, - width=12, - ).pack(side="left", padx=self._colors.space("sm")) - - if had_commitment: - self._sick_submit_button.config(state="disabled") - self._commitment_forced_remaining = SICK_COMMITMENT_FORCED_READ_SECONDS - self._update_commitment_forced_delay() - - def _add_form_widgets(self, parent: FrameGroup) -> TextGroup: - """Create symptom/onset/severity/text widgets. Returns the text widget.""" - self._add_label_entry( - parent, - label="Symptom (e.g. fever, nausea):", - variable=self._sick_symptom_var, - focus=True, - ) - self._add_label_entry( - parent, - label="When did it start? (e.g. last night):", - variable=self._sick_onset_var, - ) - sev_row = parent.child_frame(bg=self._colors.palette.bg) - sev_row.pack(pady=self._colors.space("sm")) - sev_row.child_widgets( - tk.Label, - text="Severity (1-10):", - font=self._colors.font("label"), - fg=self._colors.palette.fg, - bg=self._colors.palette.bg, - ).pack(side="left", padx=self._colors.space("xs")) - sev_row.child_widgets( - tk.Spinbox, - from_=1, - to=10, - textvariable=self._sick_severity_var, - width=4, - font=self._colors.font("label"), - ).pack(side="left", padx=self._colors.space("xs")) - - parent.child_widgets( - tk.Label, - text=(f"Describe how you feel (min {SICK_JUSTIFICATION_MIN_CHARS} chars):"), - font=self._colors.font("label"), - fg=self._colors.palette.fg, - bg=self._colors.palette.bg, - ).pack(pady=self._colors.space("sm")) - text_widgets = TextGroup( - list( - parent.child_widgets( - tk.Text, - width=60, - height=6, - font=self._colors.font("label"), - bg=self._colors.palette.field_bg, - fg=self._colors.palette.fg, - insertbackground=self._colors.palette.fg, - **self._colors.focus_kwargs(), - ) - ) - ) - text_widgets.pack(pady=self._colors.space("sm")) - for text_widget in text_widgets: - _disable_paste(text_widget) - # Tk traps inside a Text (inserts a tab, refocuses itself) and - # no-ops , so without this a keyboard-only user who tabs - # into the justification box can never reach SUBMIT -- inside a lock - # whose only other exit is waiting out the countdown. - escape_text_tab_trap(text_widget) - return text_widgets - - def _update_commitment_forced_delay(self) -> None: - """Tick down the forced-read delay then enable the submit button.""" - if self._commitment_forced_remaining > 0: - self._sick_submit_button.config( - text=f"WAIT {self._commitment_forced_remaining}s", - ) - self._commitment_forced_remaining -= 1 - self.root.after(1000, self._update_commitment_forced_delay) - else: - self._sick_submit_button.config(text="SUBMIT", state="normal") - - def _submit_sick_justification(self) -> None: - """Validate the form and either show an error or proceed to countdown.""" - symptom = self._sick_symptom_var.get() - onset = self._sick_onset_var.get() - try: - severity = int(self._sick_severity_var.get()) - except (tk.TclError, ValueError) as exc: - _logger.warning( - "Sick-day severity is not an integer (%s) — submitting it as 0, " - "which the justification validator will reject", - exc, - ) - severity = 0 - text = self._sick_text_widget.get("1.0", "end").strip() - draft = _sick_tracker.JustificationDraft( - symptom=symptom, - onset=onset, - severity=severity, - text=text, - ) - error = _sick_tracker.validate_justification(draft) - if error is not None: - self._sick_error_label.config(text=error) - return - - history = self._sick_history_cache - _sick_tracker.add_justification(history, draft) - if not _sick_tracker.save_history(history): - self._sick_error_label.config( - text="Could not persist sick history — try again", - ) - return - self._proceed_to_sick_countdown() - - # ------------------------------------------------------------------ - # Commitment prompt (after a verified workout) - # ------------------------------------------------------------------ diff --git a/screen_locker/_sick_floor.py b/screen_locker/_sick_floor.py new file mode 100644 index 0000000..4d7171b --- /dev/null +++ b/screen_locker/_sick_floor.py @@ -0,0 +1,79 @@ +"""Sick-day shutdown floor, applied live: 20:00 the moment today turns sick. + +The daily reset already resolves a sick day with ``earned_time.resolve(..., +sick=True)``. But a day is usually marked sick *after* that reset ran -- +at 10:00 from the lock screen or the phone -- and the reset is stamped once +per day. This pass closes the gap: on every 5-minute locker tick, every +``--sync-only`` / ``--apply-bonuses`` pass and right after the lock screen's +"I'm sick" tap, it lifts tonight's shutdown to +``earned_time.sick_shutdown_minutes`` (``max(20:00, current)``, capped at the +day's ceiling). + +The lift is a floor, not a bonus, exactly like the grace floor: it is added +to the same ``grace_floor.json`` lift, so a workout credited later is paid +out of it first (``_grace_floor.plan_absorb``) and lands on +``max(20:00, ladder)`` -- 20:40, not 20:00 + the workout's whole reward. + +Raise-only. Un-marking today does not lower the config until the next +daily reset re-derives it. +""" + +from __future__ import annotations + +from datetime import date +import logging +from typing import TYPE_CHECKING + +import earned_time + +from screen_locker._bonus_lock import bonus_lock +from screen_locker._constants import GRACE_STATE_FILE +from screen_locker._day import today_str +from screen_locker._earned import hhmm +from screen_locker._grace_floor import load_lift, save_lift, tonight_minutes +from screen_locker._sick import is_sick + +_logger = logging.getLogger(__name__) + + +class SickFloorMixin: + """Lifts tonight's shutdown to the sick floor; composed into ShutdownMixin.""" + + if TYPE_CHECKING: + + def _read_shutdown_config(self) -> tuple[int, int, int] | None: ... + + def _write_shutdown_config( + self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ... + ) -> bool: ... + + def _apply_sick_floor(self) -> bool: + """Raise tonight's shutdown to the sick floor if today is sick. + + Returns True when the config was raised on this call. + """ + today = today_str() + day = date.fromisoformat(today) + if not is_sick(day): + return False + with bonus_lock(GRACE_STATE_FILE): + config = self._read_shutdown_config() + if config is None: + _logger.warning("Sick floor: shutdown config unreadable; not applied") + return False + current = tonight_minutes(config, day) + floor = earned_time.sick_shutdown_minutes(current, day) + if floor <= current: + return False + mon_wed, thu_sun, morning = config + if not self._write_shutdown_config( + max(mon_wed, floor), max(thu_sun, floor), morning, restore=True + ): + _logger.warning("Sick floor: failed to write shutdown config") + return False + lift = load_lift(GRACE_STATE_FILE, today) + floor - current + save_lift(lift, GRACE_STATE_FILE) + _logger.info( + "Sick floor: shutdown %s -> %s (sick day).", hhmm(current), hhmm(floor) + ) + return True diff --git a/screen_locker/_sick_tracker.py b/screen_locker/_sick_tracker.py deleted file mode 100644 index f904771..0000000 --- a/screen_locker/_sick_tracker.py +++ /dev/null @@ -1,244 +0,0 @@ -"""Sick-day rate-limiting, workout debt, commitment, and justification tracking. - -Pure logic — no Tk imports. The UI calls into these helpers and persists -state via :func:`save_history`. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -import json -import logging -from typing import Any - -from gatelock.log_integrity import compute_entry_hmac - -from screen_locker._constants import ( - SICK_COMMITMENT_PENALTY_DAYS, - SICK_HISTORY_FILE, - SICK_HISTORY_REVIEW_COUNT, - SICK_JUSTIFICATION_MIN_CHARS, - SICK_JUSTIFICATION_PREVIEW_CHARS, -) -from screen_locker._sick_budget import ( - SickHistory, - _parse_iso, - _today_iso, - budget_summary, - compute_lockout_seconds, - count_in_window, - is_budget_exhausted, - is_sick_day, -) - -__all__ = [ - "SickHistory", - "add_sick_day", - "budget_summary", - "clear_one_debt", - "compute_lockout_seconds", - "count_in_window", - "had_commitment_for_today", - "is_budget_exhausted", - "is_sick_day", - "load_history", - "mark_commitment_broken", - "record_commitment_for_tomorrow", - "save_history", -] - -_logger = logging.getLogger(__name__) - - -def load_history() -> SickHistory: - """Read the persistent sick-day history file. - - Missing or unreadable files yield an empty :class:`SickHistory`. - """ - if not SICK_HISTORY_FILE.exists(): - return SickHistory() - try: - with SICK_HISTORY_FILE.open() as f: - data = json.load(f) - except OSError, json.JSONDecodeError: - _logger.warning("Could not read sick history; starting fresh") - return SickHistory() - return SickHistory( - sick_days=list(data.get("sick_days", [])), - debt=int(data.get("debt", 0)), - commitments=dict(data.get("commitments", {})), - broken_commitments=list(data.get("broken_commitments", [])), - justifications=list(data.get("justifications", [])), - revocations=list(data.get("revocations", [])), - ) - - -def save_history(history: SickHistory) -> bool: - """Persist ``history``. Returns True on success.""" - payload = { - "sick_days": history.sick_days, - "debt": history.debt, - "commitments": history.commitments, - "broken_commitments": history.broken_commitments, - "justifications": history.justifications, - "revocations": history.revocations, - } - try: - with SICK_HISTORY_FILE.open("w") as f: - json.dump(payload, f, indent=2) - except OSError as exc: - _logger.warning("Failed to save sick history: %s", exc) - return False - return True - - -def add_sick_day(history: SickHistory, *, today: str | None = None) -> int: - """Append today's date and increment debt. Returns new debt. - - If today appears in ``broken_commitments`` the debt grows by - :data:`SICK_COMMITMENT_PENALTY_DAYS` instead of 1. - """ - today_str = today or _today_iso() - if today_str not in history.sick_days: - history.sick_days.append(today_str) - increment = ( - SICK_COMMITMENT_PENALTY_DAYS if today_str in history.broken_commitments else 1 - ) - history.debt += increment - return history.debt - - -def clear_one_debt(history: SickHistory) -> int: - """Decrement debt by one (clamped at zero). Returns new debt.""" - if history.debt > 0: - history.debt -= 1 - return history.debt - - -def record_commitment_for_tomorrow( - history: SickHistory, - *, - today: str | None = None, -) -> str: - """Record that the user committed to working out tomorrow. - - Returns the ISO date for tomorrow. - """ - today_str = today or _today_iso() - today_dt = _parse_iso(today_str) - if today_dt is None: - return today_str - tomorrow = (today_dt + timedelta(days=1)).strftime("%Y-%m-%d") - history.commitments[tomorrow] = True - return tomorrow - - -def had_commitment_for_today( - history: SickHistory, - *, - today: str | None = None, -) -> bool: - """Return True if a commitment exists for today.""" - today_str = today or _today_iso() - return bool(history.commitments.get(today_str, False)) - - -def mark_commitment_broken( - history: SickHistory, - *, - today: str | None = None, -) -> None: - """Mark today's commitment as broken (idempotent).""" - today_str = today or _today_iso() - if today_str in history.commitments and today_str not in history.broken_commitments: - history.broken_commitments.append(today_str) - - -SICK_SEVERITY_MIN = 1 -SICK_SEVERITY_MAX = 10 - - -@dataclass -class JustificationDraft: - """User-supplied justification fields for a sick-day request.""" - - symptom: str - onset: str - severity: int - text: str - - -def validate_justification(draft: JustificationDraft) -> str | None: - """Return an error message if the justification is invalid, else None.""" - if not draft.symptom.strip(): - return "Symptom is required" - if not draft.onset.strip(): - return "Onset time is required" - if not SICK_SEVERITY_MIN <= draft.severity <= SICK_SEVERITY_MAX: - return f"Severity must be between {SICK_SEVERITY_MIN} and {SICK_SEVERITY_MAX}" - if len(draft.text.strip()) < SICK_JUSTIFICATION_MIN_CHARS: - return ( - f"Description must be at least " - f"{SICK_JUSTIFICATION_MIN_CHARS} characters " - f"(currently {len(draft.text.strip())})" - ) - return None - - -def add_justification( - history: SickHistory, - draft: JustificationDraft, - *, - today: str | None = None, -) -> dict[str, Any]: - """HMAC-sign and append a sick-day justification. - - Returns the stored entry (with ``hmac`` field if a key was available). - """ - today_str = today or _today_iso() - entry: dict[str, Any] = { - "date": today_str, - "timestamp": datetime.now(tz=UTC).isoformat(), - "symptom": draft.symptom.strip(), - "onset": draft.onset.strip(), - "severity": int(draft.severity), - "text": draft.text.strip(), - } - signature = compute_entry_hmac(entry) - if signature is not None: - entry["hmac"] = signature - history.justifications.append(entry) - return entry - - -def recent_justifications( - history: SickHistory, - n: int = SICK_HISTORY_REVIEW_COUNT, -) -> list[dict[str, Any]]: - """Return the last ``n`` justifications (oldest first).""" - if n <= 0: - return [] - return list(history.justifications[-n:]) - - -def format_recent_justifications( - history: SickHistory, - n: int = SICK_HISTORY_REVIEW_COUNT, -) -> str: - """Human-readable multi-line summary of recent justifications. - - Empty string when there are no past entries. - """ - entries = recent_justifications(history, n) - if not entries: - return "" - lines: list[str] = [] - for entry in entries: - date_str = entry.get("date", "?") - symptom = str(entry.get("symptom", "?")) - if len(symptom) > SICK_JUSTIFICATION_PREVIEW_CHARS: - symptom = symptom[: SICK_JUSTIFICATION_PREVIEW_CHARS - 1] + "…" - severity = entry.get("severity", "?") - lines.append(f"{date_str} sev {severity}/10 — {symptom}") - return "\n".join(lines) diff --git a/screen_locker/_startup_checks.py b/screen_locker/_startup_checks.py index 11a5b85..0d0f759 100644 --- a/screen_locker/_startup_checks.py +++ b/screen_locker/_startup_checks.py @@ -21,7 +21,6 @@ from screen_locker._constants import ( HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD, SHUTDOWN_BASE_FILE, - SICK_DAY_STATE_FILE, ) from screen_locker._decision_log import ( LockDecision, @@ -112,7 +111,6 @@ class StartupChecksMixin(SyncMixin): reset_to_base_if_new_day( SHUTDOWN_BASE_FILE, self, - sick_day_state_file=SICK_DAY_STATE_FILE, log_file=self.log_file, ) # A LeetCode solve or a reading credit usually lands hours after the @@ -154,10 +152,10 @@ class StartupChecksMixin(SyncMixin): f"Weekly minimum of {WEEKLY_WORKOUT_MINIMUM} workouts met.", ) return - # Only remaining same-day skip: genuine extreme heat. Sick days go - # through the justification flow instead; there is no banked - # "skip a workout" credit — that mechanic works against the goal of - # maximizing weekly workouts, so it was removed. + # Only remaining same-day skip: genuine extreme heat. A sick day is a + # date in the sickdays pool (checked in _check_today_state_exits); + # there is no banked "skip a workout" credit — that mechanic works + # against the goal of maximizing weekly workouts, so it was removed. # A logged workout outranks every rung above, whatever their order. if refuse_to_lock_over_logged_workout(self.has_logged_today, self._record_skip): return diff --git a/screen_locker/_status.py b/screen_locker/_status.py index a24a7b2..469b4a4 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_tracker import load_history +from screen_locker._sick import sick_dates from screen_locker._weekly_check import ( COUNTED_WORKOUT_TYPES, WEEKLY_WORKOUT_MINIMUM, @@ -47,14 +47,14 @@ def _load_extra_benefits() -> dict: return {} -def _print_day_line(d: date, entries: list[dict], sick_days: set[str]) -> bool: +def _print_day_line(d: date, entries: list[dict], sick_days: frozenset[date]) -> bool: """Print one day's status line(s). Returns True if any workout counted. A day may hold several workouts — each is printed on its own line. """ label = d.strftime("%a %b %d") if not entries: - if d.isoformat() in sick_days: + if d in sick_days: print(f" {label} ✗ sick_day") else: print(f" {label} — no entry") @@ -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 = set(load_history().sick_days) + sick_days = sick_dates() print("=== Weekly Workout Status ===") diff --git a/screen_locker/_status_data.py b/screen_locker/_status_data.py index 46c9de0..47a9b12 100644 --- a/screen_locker/_status_data.py +++ b/screen_locker/_status_data.py @@ -1,4 +1,4 @@ -"""Read-only status snapshot: today, this week, shutdown projection, sick budget. +"""Read-only status snapshot: today, this week, shutdown projection, budgets. Every function in this module only reads files already on disk — no ADB, no sudo, no network, no writes. ``_status.py`` stays untouched (it's an @@ -12,7 +12,6 @@ from __future__ import annotations from datetime import UTC, date, datetime, timedelta from pathlib import Path -from typing import TYPE_CHECKING from screen_locker import _manual_workout from screen_locker._compliance_state import explain_lock_decision @@ -25,7 +24,6 @@ from screen_locker._status_types import ( ManualWorkoutBudgetStatus, ShutdownProjection, ShutdownProjectionDay, - SickBudgetStatus, StatusSnapshot, WeeklySummary, ) @@ -35,7 +33,6 @@ __all__ = [ "ManualWorkoutBudgetStatus", "ShutdownProjection", "ShutdownProjectionDay", - "SickBudgetStatus", "StatusSnapshot", "WeeklySummary", "format_summary_line", @@ -47,9 +44,6 @@ from screen_locker._constants import ( MANUAL_WORKOUT_BUDGET_PER_7_DAYS, MANUAL_WORKOUT_BUDGET_PER_30_DAYS, SHUTDOWN_CONFIG_FILE, - SICK_BUDGET_PER_7_DAYS, - SICK_BUDGET_PER_30_DAYS, - SICK_BUDGET_PER_90_DAYS, ) from screen_locker._extra_benefits import ( current_streak, @@ -57,11 +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_tracker import ( - count_in_window, - is_budget_exhausted, - load_history, -) +from screen_locker._sick import sick_dates from screen_locker._weekly_check import ( COUNTED_WORKOUT_TYPES, WEEKLY_WORKOUT_MINIMUM, @@ -69,13 +59,12 @@ from screen_locker._weekly_check import ( is_relaxed_day, ) -if TYPE_CHECKING: - from screen_locker._sick_tracker import SickHistory - _DEFAULT_LOG_FILE = Path(__file__).resolve().parent / "log.json" -def _day_status(day_date: date, entries: list[dict], sick_days: set[str]) -> DayStatus: +def _day_status( + day_date: date, entries: list[dict], sick_days: frozenset[date] +) -> DayStatus: """Build a :class:`DayStatus` for one calendar day from its entry list. ``day_count`` comes from @@ -106,16 +95,14 @@ def _day_status(day_date: date, entries: list[dict], sick_days: set[str]) -> Day source=" · ".join(s for s in sources if s), counted=any(t in COUNTED_WORKOUT_TYPES for t in types), day_count=count_day_credits(iso, [e for e in entries if isinstance(e, dict)]), - is_sick_day=iso in sick_days, + is_sick_day=day_date in sick_days, ) -def _week_days( - log_file: Path, sick_history: SickHistory, *, today_local: datetime -) -> tuple[DayStatus, ...]: +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 = set(sick_history.sick_days) + sick_days = sick_dates() today_date = today_local.date() monday = today_date - timedelta(days=today_date.weekday()) days: list[DayStatus] = [] @@ -138,20 +125,6 @@ def _weekly_summary(days: tuple[DayStatus, ...]) -> WeeklySummary: ) -def _sick_budget_status(history: SickHistory, *, today: str) -> SickBudgetStatus: - """Summarize rolling sick-day budget usage.""" - return SickBudgetStatus( - used_7d=count_in_window(history, 7, today=today), - budget_7d=SICK_BUDGET_PER_7_DAYS, - used_30d=count_in_window(history, 30, today=today), - budget_30d=SICK_BUDGET_PER_30_DAYS, - used_90d=count_in_window(history, 90, today=today), - budget_90d=SICK_BUDGET_PER_90_DAYS, - debt=history.debt, - exhausted=is_budget_exhausted(history, today=today), - ) - - def _manual_workout_budget_status( log_file: Path, *, today: str ) -> ManualWorkoutBudgetStatus: @@ -182,8 +155,7 @@ def gather_status( today_local = instant.astimezone() today_str = today_local.date().isoformat() - sick_history = load_history() - days = _week_days(log_file, sick_history, today_local=today_local) + days = _week_days(log_file, today_local=today_local) week = _weekly_summary(days) early_bird_extended = has_extended_early_bird( extra_benefits_file, today=today_local @@ -192,7 +164,6 @@ def gather_status( lock_explanation = explain_lock_decision( log_file=log_file, early_bird_pending_file=early_bird_pending_file, - sick_history=sick_history, weekly_minimum_met=week.counted_count >= WEEKLY_WORKOUT_MINIMUM, relaxed_day=is_relaxed_day(today=today_local), wake_skip=has_workout_skip_today(), @@ -209,7 +180,6 @@ def gather_status( today_local=today_local, ), lock_explanation=lock_explanation, - sick_budget=_sick_budget_status(sick_history, today=today_str), manual_workout_budget=_manual_workout_budget_status(log_file, today=today_str), generated_at=instant.isoformat(), ) diff --git a/screen_locker/_status_projection.py b/screen_locker/_status_projection.py index a0d1c56..b174777 100644 --- a/screen_locker/_status_projection.py +++ b/screen_locker/_status_projection.py @@ -92,10 +92,10 @@ def format_summary_line(snapshot: StatusSnapshot) -> str: mark = "✓" if week.remaining == 0 else "…" tonight = _tonight_minutes(snapshot) tonight_str = hhmm(tonight) if tonight is not None else "?" - sick = snapshot.sick_budget + sick = " · sick day" if snapshot.today.is_sick_day else "" return ( f"{mark} {week.counted_count}/{week.minimum} workouts · " - f"{tonight_str} tonight · sick {sick.used_7d}/{sick.budget_7d}" + f"{tonight_str} tonight{sick}" ) diff --git a/screen_locker/_status_sections.py b/screen_locker/_status_sections.py index b8ca168..776a781 100644 --- a/screen_locker/_status_sections.py +++ b/screen_locker/_status_sections.py @@ -23,7 +23,6 @@ if TYPE_CHECKING: DayStatus, ManualWorkoutBudgetStatus, ShutdownProjection, - SickBudgetStatus, WeeklySummary, ) @@ -102,19 +101,6 @@ class StatusSectionsMixin: color=self._colors.palette.warning, ) - def _section_sick_budget(self, parent: tk.Widget, sick: SickBudgetStatus) -> None: - """Render rolling sick-day budget usage.""" - del parent - self._label("Sick Budget", role="body", pad="sm") - self._text( - f"{sick.used_7d}/{sick.budget_7d} week · {sick.used_30d}/{sick.budget_30d} " - f"month · {sick.used_90d}/{sick.budget_90d} quarter · debt {sick.debt}", - role="body", - color=self._colors.palette.danger - if sick.exhausted - else self._colors.palette.muted, - ) - def _section_manual_workout_budget( self, parent: tk.Widget, manual: ManualWorkoutBudgetStatus ) -> None: diff --git a/screen_locker/_status_types.py b/screen_locker/_status_types.py index 41f841d..75853d6 100644 --- a/screen_locker/_status_types.py +++ b/screen_locker/_status_types.py @@ -41,20 +41,6 @@ class WeeklySummary: extra: int -@dataclass(frozen=True) -class SickBudgetStatus: - """Rolling sick-day budget usage.""" - - used_7d: int - budget_7d: int - used_30d: int - budget_30d: int - used_90d: int - budget_90d: int - debt: int - exhausted: bool - - @dataclass(frozen=True) class ManualWorkoutBudgetStatus: """Rolling manual-workout budget usage.""" @@ -98,6 +84,5 @@ class StatusSnapshot: early_bird_extended: bool shutdown: ShutdownProjection lock_explanation: LockExplanation - sick_budget: SickBudgetStatus manual_workout_budget: ManualWorkoutBudgetStatus generated_at: str diff --git a/screen_locker/_status_view_verify.py b/screen_locker/_status_view_verify.py index 287836e..793cdb7 100644 --- a/screen_locker/_status_view_verify.py +++ b/screen_locker/_status_view_verify.py @@ -220,7 +220,7 @@ class PhoneCheckMixin: Shared by the "Check Phone" verify path and the "Log Manual Workout" path so both report the reward identically: the base +2h shutdown push for the day's first workout, the +1h bonus for an additional same-day - verified workout, and any debt cleared — or a note when this exact + verified workout — or a note when this exact workout was already recorded today (idempotent, no new credit). """ lines = [header] @@ -233,6 +233,4 @@ class PhoneCheckMixin: lines.append( f"Extra workout today! +{span(credit.extra_bonus_delta)} tonight" ) - if credit.new_debt is not None: - lines.append(f"Workout debt: {credit.new_debt}") return lines diff --git a/screen_locker/_surface_group.py b/screen_locker/_surface_group.py index 027ac11..b108c4c 100644 --- a/screen_locker/_surface_group.py +++ b/screen_locker/_surface_group.py @@ -13,10 +13,10 @@ proxy that mirrors later ``configure`` calls to every copy. Nesting works the same way: a frame group can be the parent of another group, so ``_button_row()`` still reads like it returns one frame. -The degenerate case matters as much as the general one. The status window, -the post-sick-day verify window and the relaxed-day prompt are ordinary -single windows with no lock and no surfaces; they build a group of exactly -one frame and every call site below behaves as it always did. +The degenerate case matters as much as the general one. The status window +and the relaxed-day prompt are ordinary single windows with no lock and no +surfaces; they build a group of exactly one frame and every call site below +behaves as it always did. """ from __future__ import annotations @@ -78,28 +78,6 @@ class WidgetGroup[W: tk.Misc]: widget.destroy() -class TextGroup(WidgetGroup[tk.Text]): - """A multi-line text box, mirrored onto every monitor. - - Entries can share a ``StringVar`` and so are typed once for all screens. - ``tk.Text`` has no such variable, so the copies genuinely diverge: the - user types into exactly one of them, whichever screen they are looking - at. Reading therefore returns the first copy with anything in it, which - keeps the "answer on any monitor" property the per-output lock exists - for. Falls back to the primary's (empty) content so callers still get a - string to validate and reject. - """ - - def get(self, start: str, end: str) -> str: - """Return the content of whichever copy was actually typed into.""" - for widget in self._widgets: - with contextlib.suppress(tk.TclError): - content = widget.get(start, end) - if content.strip(): - return str(content) - return str(self._widgets[0].get(start, end)) - - class FrameGroup(WidgetGroup[tk.Frame]): """A container, mirrored onto every monitor, that can parent more groups. diff --git a/screen_locker/_sync_mixin.py b/screen_locker/_sync_mixin.py index 898b5c0..7732fe7 100644 --- a/screen_locker/_sync_mixin.py +++ b/screen_locker/_sync_mixin.py @@ -2,7 +2,7 @@ Split out of ``screen_lock.py`` to keep every file under the 250-line cap. These methods form one cohesive unit: they publish this PC's workouts, ingest -records other devices produced, and apply the same shutdown/debt credit a +records other devices produced, and apply the same shutdown credit a live-logged workout earns. """ @@ -56,7 +56,7 @@ class SyncMixin: def _ingest_synced_manual_workouts(self) -> None: """Sync manual workouts: publish this PC's, ingest everyone else's. - Each newly-ingested record earns the identical shutdown/debt reward a + Each newly-ingested record earns the identical shutdown reward a live-logged workout would (see ``WorkoutCreditMixin._apply_credit_for_written_entry``), regardless of whether it's dated today or back-dated — there's only one current diff --git a/screen_locker/_ui_flows.py b/screen_locker/_ui_flows.py index e07e0e7..7221107 100644 --- a/screen_locker/_ui_flows.py +++ b/screen_locker/_ui_flows.py @@ -5,7 +5,7 @@ from __future__ import annotations from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module from typing import TYPE_CHECKING -from screen_locker import _manual_workout, _sick_tracker +from screen_locker import _manual_workout from screen_locker._source_health import collect_source_findings, explain_findings from screen_locker._ui_flows_lockout import LockoutFlowMixin from screen_locker._ui_flows_sick import SickDayFlowMixin @@ -54,7 +54,7 @@ class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin): self.root.after(500, self._poll_phone_check) def _show_retry_and_sick(self, message: str) -> None: - """Show TRY AGAIN and (if budget allows) I'm sick after a failed check.""" + """Show TRY AGAIN, I'm sick and (budget allowing) the manual log.""" self.clear_container() self._label( "No Workout Found", @@ -70,10 +70,6 @@ class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin): diagnosis = explain_findings(collect_source_findings()) if diagnosis: self._text(diagnosis, color=self._colors.palette.muted) - history = _sick_tracker.load_history() - self._text( - _sick_tracker.budget_summary(history), color=self._colors.palette.muted - ) self._text( _manual_workout.budget_summary(self.log_file), color=self._colors.palette.muted, @@ -86,19 +82,7 @@ class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin): command=self._start_phone_check, width=12, ).pack(side="left", padx=self._colors.space("sm")) - if _sick_tracker.is_budget_exhausted(history): - self._text( - "Sick budget exhausted. No 'I'm sick' option available.", - color=self._colors.palette.danger, - ) - else: - self._button( - frame, - "I'm sick", - bg=self._colors.palette.warning, - command=self.ask_if_sick, - width=12, - ).pack(side="left", padx=self._colors.space("sm")) + self._sick_button(frame) if _manual_workout.is_budget_exhausted(self.log_file): self._text( "Manual-workout budget exhausted. No manual-log option available.", diff --git a/screen_locker/_ui_flows_lockout.py b/screen_locker/_ui_flows_lockout.py index 153dbde..82879f9 100644 --- a/screen_locker/_ui_flows_lockout.py +++ b/screen_locker/_ui_flows_lockout.py @@ -73,7 +73,7 @@ class LockoutFlowMixin: if self.demo_mode else PHONE_PENALTY_DELAY_PRODUCTION ) - # Disconnecting the phone shouldn't be a fast path into sick mode. + # Disconnecting the phone shouldn't be a fast path to the manual log. delay = ( base_delay if self.demo_mode @@ -99,6 +99,8 @@ class LockoutFlowMixin: scale=2.5, pad="md", ) + # A sick day needs no wait: it is one tap on every screen. + self._sick_button(self._button_row()) self._update_phone_penalty() def _update_phone_penalty(self) -> None: diff --git a/screen_locker/_ui_flows_relaxed.py b/screen_locker/_ui_flows_relaxed.py index bd029f7..ad2b19e 100644 --- a/screen_locker/_ui_flows_relaxed.py +++ b/screen_locker/_ui_flows_relaxed.py @@ -1,4 +1,4 @@ -"""Verify-workout and relaxed-day UI flow methods mixin.""" +"""Relaxed-day (Tue/Wed/Thu) UI flow methods mixin.""" from __future__ import annotations @@ -12,94 +12,7 @@ from screen_locker._weekly_check import ( class UIFlowsRelaxedMixin: - """Mixin providing verify-workout and relaxed-day UI flow logic.""" - - # ------------------------------------------------------------------ - # Verify-workout flow (post-sick-day) - # ------------------------------------------------------------------ - - def _start_verify_workout_check(self) -> None: - """Start phone check for post-sick-day workout verification.""" - self.clear_container() - self._label( - "Verifying Workout", - role="display", - color=self._colors.palette.warning, - pad="lg", - ) - self._text( - "Checking phone for today's workout...", - role="body", - ) - executor = ThreadPoolExecutor(max_workers=1) - self._phone_future = executor.submit(self._verify_phone_workout) - executor.shutdown(wait=False) - self._poll_verify_workout_check() - - def _poll_verify_workout_check(self) -> None: - """Poll background phone check for verify-workout mode.""" - if self._phone_future is not None and self._phone_future.done(): - status, message = self._phone_future.result() - self._handle_verify_workout_result(status, message) - else: - self.root.after(500, self._poll_verify_workout_check) - - def _handle_verify_workout_result( - self, - status: str, - message: str, - ) -> None: - """Route phone check result in verify-workout mode.""" - if status == "verified": - self.workout_data["type"] = "phone_verified" - self.workout_data["source"] = message - self.workout_data["after_sick_day"] = "true" - adjusted = self._adjust_shutdown_time_later() - self.save_workout_log() - self.clear_container() - self._label( - "✓ Workout Verified!", - role="display", - scale=1.3, - color=self._colors.palette.success, - pad="lg", - ) - self._text(message, role="subtitle", color=self._colors.palette.success) - if adjusted: - self._text( - "Shutdown time moved later!", - role="subtitle", - color=self._colors.palette.warning, - ) - self.root.after(2000, self.close) - else: - self._show_verify_retry(message) - - def _show_verify_retry(self, message: str) -> None: - """Show retry/close buttons when workout not found in verify mode.""" - self.clear_container() - self._label( - "Workout Not Found", - role="display", - color=self._colors.palette.danger, - pad="md", - ) - self._text(message, color=self._colors.palette.warning) - frame = self._button_row() - self._button( - frame, - "TRY AGAIN", - bg=self._colors.palette.accent, - command=self._start_verify_workout_check, - width=12, - ).pack(side="left", padx=self._colors.space("sm")) - self._button( - frame, - "Close", - bg=self._colors.palette.field_bg, - command=self.close, - width=12, - ).pack(side="left", padx=self._colors.space("sm")) + """Mixin providing the relaxed-day UI flow logic.""" # ------------------------------------------------------------------ # Relaxed-day flow (Tue/Wed/Thu — optional, no penalty for skipping) @@ -141,6 +54,9 @@ class UIFlowsRelaxedMixin: command=self._start_relaxed_phone_check, width=20, ).pack(side="left", padx=self._colors.space("sm")) + # Skipping is free here, but a sick day is more than a skip: it + # stands every gate down and lifts tonight's shutdown to 20:00. + self._sick_button(frame) def _skip_relaxed_day(self) -> None: """Dismiss today's relaxed-day prompt for the rest of the calendar day. @@ -155,7 +71,7 @@ class UIFlowsRelaxedMixin: self.close() def _start_relaxed_phone_check(self) -> None: - """Run Stronglift check in relaxed mode (no screen grab, no sick option).""" + """Run Stronglift check in relaxed mode (no screen grab).""" self.clear_container() self._label( "Checking phone...", @@ -181,7 +97,7 @@ class UIFlowsRelaxedMixin: """Route phone check result in relaxed-day mode. On success saves the workout (counts toward weekly total) then closes. - On failure shows retry and close — no sick option since skipping is free. + On failure shows retry, I'm sick and close. """ if status == "verified": self.workout_data["type"] = "phone_verified" @@ -211,6 +127,7 @@ class UIFlowsRelaxedMixin: command=self._start_relaxed_phone_check, width=12, ).pack(side="left", padx=self._colors.space("sm")) + self._sick_button(frame) self._button( frame, "Close (Skip)", diff --git a/screen_locker/_ui_flows_sick.py b/screen_locker/_ui_flows_sick.py index 0c1c10d..9e8b5f2 100644 --- a/screen_locker/_ui_flows_sick.py +++ b/screen_locker/_ui_flows_sick.py @@ -1,94 +1,86 @@ -"""Sick-day flow for the screen locker's locked screen. +"""The "I'm sick" button: one tap marks today sick and unlocks. + +A sick day is a date in the shared ``sickdays`` pool (``_sick.py``). There is +no budget, countdown, justification, commitment or debt any more: the tap +writes today into the local pool, pushes it in the background (best +effort, so the phone sees it before the next sickdays-sync tick), raises +tonight's shutdown to the sick floor right +away (``_sick_floor``) and closes the lock. Every other gate reads the same +pool and stands down too. + +The button is offered on every screen that can end in a lock or a prompt: +the retry screen, the no-phone penalty screen and both relaxed-day screens. +A refusal (a free day, an unreadable pool) is shown on screen and the lock +stays up. Split out of :mod:`screen_locker._ui_flows` to keep every file under the -250-line cap. Composed back into ``UIFlowsMixin`` there, so the set of mixins -``ScreenLocker`` inherits is unchanged. +250-line cap. Composed back into ``UIFlowsMixin`` there. """ from __future__ import annotations -from screen_locker import _sick_tracker +from typing import TYPE_CHECKING + +from screen_locker._constants import SHUTDOWN_BASE_FILE +from screen_locker._credit_slot import locked_shutdown_state +from screen_locker._sick import mark_today_sick, push_in_background + +if TYPE_CHECKING: + import tkinter as tk class SickDayFlowMixin: - """Sick-day justification, countdown and debt bookkeeping.""" + """The one-tap sick-day button and what follows it.""" - def ask_if_sick(self) -> None: - """Display the structured sick-day justification dialog.""" - self._show_sick_justification() + def _sick_button(self, frame: tk.Widget) -> None: + """Pack the "I'm sick" button into ``frame``.""" + self._button( + frame, + "I'm sick", + bg=self._colors.palette.warning, + command=self.mark_sick_and_unlock, + width=12, + ).pack(side="left", padx=self._colors.space("sm")) - def _get_sick_day_status(self) -> tuple[str, str]: - """Determine sick day status text and color.""" - if self._sick_mode_used_today(): - return "Shutdown time already adjusted today", self._colors.palette.warning - if self._adjust_shutdown_time_earlier(): - return ( - "Shutdown time moved 1.5 hours earlier ✓\n(Will revert tomorrow)" - ), self._colors.palette.success - return ( - "Could not adjust shutdown time (check permissions)", - self._colors.palette.danger, + def mark_sick_and_unlock(self) -> None: + """Mark today sick, lift tonight's shutdown to 20:00 and unlock.""" + result = mark_today_sick() + if not result.ok: + self._show_sick_refused(result.message) + return + # Now, not at the next 5-minute tick: while this window is up that + # tick stands down at the instance lock. + with locked_shutdown_state(SHUTDOWN_BASE_FILE): + self._apply_sick_floor() + # Best effort and off the UI thread: the unlock never waits for it. + push_in_background() + self._record_decision( + locked=False, + reason="sick_day_marked", + detail=f"Marked today sick from the lock screen. {result.message}", ) + self.close() - def _proceed_to_sick_countdown(self) -> None: - """Start the (escalated) sick day countdown after justification.""" - history = getattr( - self, - "_sick_history_cache", - None, - ) - if history is None: - history = _sick_tracker.load_history() - self._sick_history_cache = history - countdown = _sick_tracker.compute_lockout_seconds(history) + def _show_sick_refused(self, message: str) -> None: + """Say why today could not be marked sick; the lock stays up.""" self.clear_container() - status_text, status_color = self._get_sick_day_status() - self._show_sick_day_ui(status_text, status_color, countdown) - self.sick_remaining_time = countdown - self._update_sick_countdown() - - def _show_sick_day_ui( - self, - status_text: str, - status_color: str, - countdown: int, - ) -> None: - """Display sick day UI labels and countdown.""" - self._label("Sick Day Mode", color=self._colors.palette.warning, pad="md") - self._text(status_text, color=status_color) - minutes = countdown // 60 - self._text( - f"Please wait ~{minutes} min before unlocking...", - role="title", + self._label( + "Could not mark today as sick", + role="display", + color=self._colors.palette.danger, pad="md", ) - self.sick_countdown_label = self._label( - str(countdown), - role="display", - scale=2.5, - pad="lg", + self._text(message, color=self._colors.palette.warning) + frame = self._button_row() + back = ( + self._start_relaxed_day_flow + if self._relaxed_day_mode + else self._start_phone_check ) - - def _update_sick_countdown(self) -> None: - """Update the sick day countdown timer.""" - if self.sick_remaining_time > 0: - self.sick_countdown_label.config(text=str(self.sick_remaining_time)) - self.sick_remaining_time -= 1 - self.root.after(1000, self._update_sick_countdown) - else: - self._finalize_sick_day() - - def _finalize_sick_day(self) -> None: - """Persist sick-day history and unlock the screen.""" - history = getattr(self, "_sick_history_cache", None) - if history is None: - history = _sick_tracker.load_history() - if _sick_tracker.had_commitment_for_today(history): - _sick_tracker.mark_commitment_broken(history) - self.workout_data["broke_commitment"] = "true" - new_debt = _sick_tracker.add_sick_day(history) - _sick_tracker.save_history(history) - self.workout_data["type"] = "sick_day" - self.workout_data["note"] = "Sick day - shutdown moved earlier" - self.workout_data["debt"] = str(new_debt) - self.unlock_screen() + self._button( + frame, + "Back", + bg=self._colors.palette.accent, + command=back, + width=12, + ).pack(side="left", padx=self._colors.space("sm")) diff --git a/screen_locker/_ui_form_fields.py b/screen_locker/_ui_form_fields.py index 3cefb95..853009e 100644 --- a/screen_locker/_ui_form_fields.py +++ b/screen_locker/_ui_form_fields.py @@ -1,11 +1,8 @@ -"""Labelled form fields and initial focus, shared by every lock surface. +"""Paste friction and initial focus, shared by every lock surface. Split out of :mod:`screen_locker._ui_widgets` to keep every file under the 250-line cap. Composed back into ``UIWidgetsMixin`` there, so every host class keeps the same surface. - -Each helper builds its widget on *every* monitor (the lock spans them all), -which is why they return groups rather than single widgets. """ from __future__ import annotations @@ -14,10 +11,8 @@ import contextlib import tkinter as tk from typing import TYPE_CHECKING -from gatelock import escape_text_tab_trap - if TYPE_CHECKING: - from screen_locker._surface_group import FrameGroup, WidgetGroup + from screen_locker._surface_group import FrameGroup def disable_paste(widget: tk.Widget) -> None: @@ -33,95 +28,7 @@ def disable_paste(widget: tk.Widget) -> None: class UIFormFieldsMixin: - """Labelled entry/text fields and initial focus for the lock surfaces.""" - - def _add_label_entry( - self, - parent: FrameGroup, - *, - label: str, - variable: tk.StringVar, - focus: bool = False, - ) -> None: - """Add a label + single-line entry pair on every monitor. - - All copies share one ``variable``, so typing on any screen is the - same text everywhere and reading it back needs no idea which monitor - the user actually used. - - ``focus`` gives the primary surface's copy the keyboard focus. Exactly - one field per screen should ask: the lock takes a global grab, so with - nothing focused the user's typing goes nowhere and there is no other - window on screen to make that obvious. - """ - row = parent.child_frame(bg=self._colors.palette.bg) - row.pack(pady=self._colors.space("sm"), fill="x") - row.child_widgets( - tk.Label, - text=label, - font=self._colors.font("label"), - fg=self._colors.palette.fg, - bg=self._colors.palette.bg, - anchor="w", - ).pack(side="top", anchor="w") - entries = row.child_widgets( - tk.Entry, - textvariable=variable, - width=50, - font=self._colors.font("label"), - bg=self._colors.palette.field_bg, - fg=self._colors.palette.fg, - insertbackground=self._colors.palette.fg, - **self._colors.focus_kwargs(), - ) - entries.pack(side="top", anchor="w", pady=self._colors.space("xs")) - for entry in entries: - disable_paste(entry) - if focus: - entries.first.focus_set() - - def _add_label_text( - self, - parent: FrameGroup, - *, - label: str, - height: int = 4, - ) -> WidgetGroup: - """Add a label + multi-line ``Text`` pair on every monitor. - - Use this instead of building a bare ``tk.Text``: it un-traps Tab. - Tk makes ```` insert a literal tab and refocus the widget, and - binds ```` to nothing, so an untreated multi-line field is a - keyboard dead end whose only exits (``Ctrl+Tab``/``Ctrl+Shift+Tab``) are - not advertised anywhere. On a lock surface that means a user who tabs - into it can never reach the submit button. - """ - row = parent.child_frame(bg=self._colors.palette.bg) - row.pack(pady=self._colors.space("sm"), fill="x") - row.child_widgets( - tk.Label, - text=label, - font=self._colors.font("label"), - fg=self._colors.palette.fg, - bg=self._colors.palette.bg, - anchor="w", - ).pack(side="top", anchor="w") - boxes = row.child_widgets( - tk.Text, - width=60, - height=height, - wrap="word", - font=self._colors.font("label"), - bg=self._colors.palette.field_bg, - fg=self._colors.palette.fg, - insertbackground=self._colors.palette.fg, - **self._colors.focus_kwargs(), - ) - boxes.pack(side="top", anchor="w", pady=self._colors.space("xs")) - for box in boxes: - disable_paste(box) - escape_text_tab_trap(box) - return boxes + """Initial focus for the lock surfaces.""" def _focus_first_button(self, row: FrameGroup) -> None: """Give the primary surface's first button keyboard focus. diff --git a/screen_locker/_ui_widgets.py b/screen_locker/_ui_widgets.py index 714b700..f48f91e 100644 --- a/screen_locker/_ui_widgets.py +++ b/screen_locker/_ui_widgets.py @@ -17,8 +17,8 @@ if TYPE_CHECKING: from screen_locker._surface_group import FrameGroup, WidgetGroup # disable_paste moved to _ui_form_fields with the fields that use it; it is -# re-exported here because _manual_workout_widgets and _sick_dialog import it -# from this module. +# re-exported here because _manual_workout_widgets imports it from this +# module. __all__ = ["UIWidgetsMixin", "disable_paste"] diff --git a/screen_locker/_unlock_view.py b/screen_locker/_unlock_view.py index 08b5272..78b49e2 100644 --- a/screen_locker/_unlock_view.py +++ b/screen_locker/_unlock_view.py @@ -1,9 +1,8 @@ """The success screen shown once a workout has been credited. Split out of ``screen_lock`` so that module stays inside the repo's 400-line -cap; this is pure view code (paint the credit, then hand off to the -commitment prompt or the close), which is exactly the kind of thing the other -``_ui_*`` mixins already own. +cap; this is pure view code (paint the credit, then close), which is exactly +the kind of thing the other ``_ui_*`` mixins already own. """ from __future__ import annotations @@ -14,14 +13,9 @@ from screen_locker._extra_benefits import current_streak _UNLOCK_DELAY_MS = 1500 -# Workout kinds that carry a commitment prompt on the way out: they are the -# ones the user actually performed today, so "will you work out tomorrow?" is -# a question worth asking. -_COMMITTABLE = ("phone_verified", "runnerup_verified", "manual_workout") - class UnlockViewMixin: - """Paints the unlock screen and routes to whatever follows it.""" + """Paints the unlock screen, then closes it.""" def unlock_screen(self) -> None: """Apply workout credit and display the success message.""" @@ -47,14 +41,6 @@ class UnlockViewMixin: role="subtitle", color=self._colors.palette.warning, ) - if credit.new_debt is not None: - self._text( - f"Workout debt: {credit.new_debt}", - role="subtitle", - color=self._colors.palette.warning - if credit.new_debt > 0 - else self._colors.palette.muted, - ) streak = current_streak(EXTRA_BENEFITS_FILE) if streak >= 1: self._text( @@ -63,10 +49,4 @@ class UnlockViewMixin: color=self._colors.palette.muted, ) self._text("Screen Unlocked!", role="display", pad="md") - if self.workout_data.get("type") in _COMMITTABLE: - self.root.after( - _UNLOCK_DELAY_MS, - lambda: self._show_commitment_prompt(on_done=self.close), - ) - else: - self.root.after(_UNLOCK_DELAY_MS, self.close) + self.root.after(_UNLOCK_DELAY_MS, self.close) diff --git a/screen_locker/_window_setup.py b/screen_locker/_window_setup.py index 2ad299d..d809fa7 100644 --- a/screen_locker/_window_setup.py +++ b/screen_locker/_window_setup.py @@ -3,8 +3,7 @@ The fullscreen lock-window mechanics (overrideredirect, input grab, VT-disable) now live in the shared ``gatelock`` package. This module keeps only the screen-locker-specific windows that are never the lock itself: the -post-sick-day verification window, the demo close button, and the optional -relaxed-day prompt. +demo close button and the optional relaxed-day prompt. """ from __future__ import annotations @@ -171,9 +170,9 @@ class WindowSetupMixin: ring that (before the focus-ring fix) was invisible black-on-black, with no way to tell what was selected or whether the keypress had landed. - The screens that take typed input (the manual-workout form, the sick - dialog) still focus their own first field as they are built; this only - covers the button-only screens that previously focused nothing. + The screen that takes typed input (the manual-workout form) still + focuses its own first field as it is built; this only covers the + button-only screens that previously focused nothing. ``surface`` is None when no output is live at all -- the lock is held with nothing to show, which is still a valid state and needs no focus. @@ -200,10 +199,6 @@ class WindowSetupMixin: def on_close(self) -> None: """No extra hardware/state beyond what close() already handles.""" - def _setup_verify_window(self) -> None: - """Configure window for post-sick-day workout verification.""" - self._setup_aux_window(min_width=600, min_height=400) - def _setup_demo_close_button(self) -> None: """Add close button for demo mode.""" close_btn = tk.Button( diff --git a/screen_locker/_workday_penalty.py b/screen_locker/_workday_penalty.py index efc3fe1..5545446 100644 --- a/screen_locker/_workday_penalty.py +++ b/screen_locker/_workday_penalty.py @@ -6,10 +6,16 @@ instead of just resetting to normal. This module never re-derives that policy: the file carries a signed ``penalty_date``, and the only decision 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. """ from __future__ import annotations +from datetime import date import json import logging from typing import TYPE_CHECKING @@ -18,6 +24,7 @@ from gatelock.log_integrity import verify_entry_hmac from screen_locker._constants import WORKDAY_PENALTY_FILE from screen_locker._day import today_str +from screen_locker._sick import is_sick if TYPE_CHECKING: from datetime import datetime @@ -44,6 +51,22 @@ def _read_verified() -> dict[str, object] | None: def workday_penalty_today(now: datetime | None = None) -> bool: """Whether today lost its relaxed-day leniency to a missed prior ring.""" entry = _read_verified() - if entry is None: + if entry is None or entry.get("penalty_date") != today_str(now): return False - return entry.get("penalty_date") == today_str(now) + 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) + return False + return True + + +def _is_sick_iso(value: object) -> bool: + """Whether ``value`` is a ``YYYY-MM-DD`` that the sickdays pool marks sick.""" + if value is None: + return False # an older file without issued_date: penalty_date rules + try: + day = date.fromisoformat(str(value)) + except ValueError: + _logger.warning("Workday penalty: %r is not a date; not a sick day", value) + return False + return is_sick(day) diff --git a/screen_locker/_workout_credit.py b/screen_locker/_workout_credit.py index 4db4276..d589efb 100644 --- a/screen_locker/_workout_credit.py +++ b/screen_locker/_workout_credit.py @@ -1,4 +1,4 @@ -"""Shared workout-credit logic: save + shutdown bonus + debt-clear. +"""Shared workout-credit logic: save + shutdown bonus. Extracted from ``screen_lock.py`` so both the locked-screen flow (``ScreenLocker.unlock_screen``) and the voluntary ``StatusWindow`` @@ -16,17 +16,13 @@ from typing import TYPE_CHECKING import earned_time -from screen_locker import _credit_notify, _sick_tracker +from screen_locker import _credit_notify from screen_locker._constants import SHUTDOWN_BASE_FILE from screen_locker._credit_slot import CreditSlot, credit_slot, locked_shutdown_state from screen_locker._day import today_str from screen_locker._earned import extra_minutes from screen_locker._rest_day import is_rest_day -from screen_locker._weekly_check import ( - COUNTED_WORKOUT_TYPES, - PC_WORKOUT_TYPE, - count_weekly_workouts, -) +from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES, count_weekly_workouts if TYPE_CHECKING: from pathlib import Path @@ -55,14 +51,13 @@ class WorkoutCreditResult: """Outcome of :meth:`WorkoutCreditMixin._apply_workout_credit`.""" shutdown_adjusted: bool - new_debt: int | None extra_bonus_delta: int weekly_count: int already_counted_today: bool class WorkoutCreditMixin: - """Save a workout entry and apply its shutdown/debt/extra-bonus reward. + """Save a workout entry and apply its shutdown/extra-bonus reward. Requires ``self.workout_data``, ``self.log_file``, and the ``LogMixin``/ ``ShutdownMixin`` methods it calls into. @@ -81,30 +76,10 @@ class WorkoutCreditMixin: _logger.info("Shutdown time moved later as workout reward") return adjusted - def _clear_debt_on_verified_workout(self) -> int | None: - """Decrement workout debt by one for a verified workout. - - Returns the new debt count, or ``None`` when this wasn't a - phone-verified workout. - """ - if self.workout_data.get("type") not in ( - "phone_verified", - "runnerup_verified", - PC_WORKOUT_TYPE, - "manual_workout", - ): - return None - history = _sick_tracker.load_history() - if history.debt <= 0: - return 0 - new_debt = _sick_tracker.clear_one_debt(history) - _sick_tracker.save_history(history) - return new_debt - def _apply_credit_for_written_entry( self, prior_entries: list[dict] ) -> WorkoutCreditResult: - """Apply the shutdown/debt reward for an already-written ``workout_data``. + """Apply the shutdown reward for an already-written ``workout_data``. Shared by :meth:`_apply_workout_credit` (the live locked-screen, Status-Window, and Check-Phone paths, which always write under today) @@ -133,14 +108,13 @@ class WorkoutCreditMixin: if slot is CreditSlot.OCCUPIED: _logger.warning( "Workout %s (%s) shares its credit slot with an entry already " - "credited today — no shutdown time, no debt change; the daily " + "credited today — no shutdown time; the daily " "reset counts the two as one workout too", self.workout_data.get("type", ""), self.workout_data.get("source", ""), ) return WorkoutCreditResult( shutdown_adjusted=False, - new_debt=None, extra_bonus_delta=0, weekly_count=weekly_count, already_counted_today=True, @@ -174,13 +148,11 @@ class WorkoutCreditMixin: extra_bonus_delta = new_cfg[1] - before[1] after = self._read_shutdown_config() - new_debt = self._clear_debt_on_verified_workout() if slot is not CreditSlot.NONE: self._notify_credit(before, after) return WorkoutCreditResult( shutdown_adjusted=shutdown_adjusted, - new_debt=new_debt, extra_bonus_delta=extra_bonus_delta, weekly_count=weekly_count, already_counted_today=False, @@ -216,24 +188,12 @@ class WorkoutCreditMixin: (a duplicate — same ``workout_id`` already logged — earns no new credit). """ - # sick_day is already persisted to sick_history.json by - # _finalize_sick_day — log.json is reserved for real outcomes. - if self.workout_data.get("type") == "sick_day": - return WorkoutCreditResult( - shutdown_adjusted=False, - new_debt=None, - extra_bonus_delta=0, - weekly_count=count_weekly_workouts(self.log_file), - already_counted_today=False, - ) - result = self.save_workout_log() if not result.appended: # A workout with this id was already recorded today — idempotent. return WorkoutCreditResult( shutdown_adjusted=False, - new_debt=None, extra_bonus_delta=0, weekly_count=count_weekly_workouts(self.log_file), already_counted_today=True, diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 10cae1b..d92b687 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -33,7 +33,6 @@ from screen_locker._constants import ( PHONE_PENALTY_DELAY_DEMO, PHONE_PENALTY_DELAY_PRODUCTION, SCHEDULED_SKIPS_FILE, - SICK_LOCKOUT_SECONDS, ) from screen_locker._decision_log import record_run_aborted from screen_locker._early_bird import EarlyBirdMixin @@ -43,7 +42,6 @@ from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin from screen_locker._phone_verification import PhoneVerificationMixin from screen_locker._runnerup_verification import RunnerUpVerificationMixin from screen_locker._shutdown import ShutdownMixin -from screen_locker._sick_dialog import SickDialogMixin from screen_locker._startup_checks import StartupChecksMixin from screen_locker._surface_group import FrameGroup from screen_locker._ui_flows import UIFlowsMixin @@ -68,7 +66,6 @@ __all__ = [ "PHONE_PENALTY_DELAY_DEMO", "PHONE_PENALTY_DELAY_PRODUCTION", "SCHEDULED_SKIPS_FILE", - "SICK_LOCKOUT_SECONDS", "WEEKLY_WORKOUT_MINIMUM", "ScreenLocker", ] @@ -102,7 +99,6 @@ class ScreenLocker( ShutdownMixin, PhoneVerificationMixin, RunnerUpVerificationMixin, - SickDialogMixin, StartupChecksMixin, UIFlowsMixin, UIFlowsRelaxedMixin, @@ -115,7 +111,6 @@ class ScreenLocker( self, *, demo_mode: bool = True, - verify_only: bool = False, ) -> None: """Initialize screen locker with optional demo mode.""" _assert_not_under_pytest() @@ -138,10 +133,9 @@ class ScreenLocker( self._instance_lock: _instance.InstanceLock = instance_lock script_dir = Path(__file__).resolve().parent self.log_file = script_dir / "log.json" - self.verify_only = verify_only self.workout_data: dict[str, str] = {} self._relaxed_day_mode: bool = False - self._check_early_exits(verify_only=verify_only) + self._check_early_exits() try: self.root = GateRoot() except tk.TclError as exc: @@ -172,10 +166,7 @@ class ScreenLocker( self._instance_lock.release() sys.exit(0) self.root.on_callback_error = self.on_callback_error - title_suffix = ( - " [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "") - ) - self.root.title("Workout Locker" + title_suffix) + self.root.title("Workout Locker" + (" [DEMO MODE]" if demo_mode else "")) self.demo_mode = demo_mode self.lockout_time = 10 if demo_mode else 1800 self._lock: LockWindow | None = None @@ -199,9 +190,7 @@ class ScreenLocker( ) # Built before setup(), which calls build_surface per live output. self.container = FrameGroup([]) - if verify_only: - self._setup_verify_window() - elif self._relaxed_day_mode: + if self._relaxed_day_mode: self._setup_relaxed_day_window() else: self._lock = self._build_lock_window() @@ -211,9 +200,7 @@ class ScreenLocker( self._phone_future: Future[tuple[str, str]] | None = None self._runnerup_future: Future[tuple[str, str]] | None = None self._runnerup_on_failure: Callable[[], None] | None = None - if verify_only: - self._start_verify_workout_check() - elif self._relaxed_day_mode: + if self._relaxed_day_mode: self._start_relaxed_day_flow() else: self._start_phone_check() diff --git a/screen_locker/status_view.py b/screen_locker/status_view.py index 05441ec..cd0f78d 100644 --- a/screen_locker/status_view.py +++ b/screen_locker/status_view.py @@ -118,7 +118,6 @@ class StatusWindow( self._section_week(self.container, snapshot.week) self._section_lock_explanation(self.container, snapshot.lock_explanation) self._section_temperature(self.container) - self._section_sick_budget(self.container, snapshot.sick_budget) self._section_manual_workout_budget( self.container, snapshot.manual_workout_budget ) @@ -186,7 +185,7 @@ class StatusWindow( submitted the evidence form, so this is not a silent log. Uses the same ``_apply_workout_credit`` the locked screen's ``unlock_screen`` uses, so this voluntary path earns the identical shutdown-later - bonus / debt-clear / extra-bonus credit (guarded against being + bonus / extra-bonus credit (guarded against being applied twice on the same day). """ verifier = self.verifier_factory(self.log_file) diff --git a/screen_locker/tests/_isolated_state.py b/screen_locker/tests/_isolated_state.py index 163503d..0c5a911 100644 --- a/screen_locker/tests/_isolated_state.py +++ b/screen_locker/tests/_isolated_state.py @@ -11,10 +11,6 @@ from __future__ import annotations # time. All of them need patching, not just the _constants source -- a missed # binding lets a test write to the real file. ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = ( - ( - "sick_history.json", - ("_sick_tracker.SICK_HISTORY_FILE", "_constants.SICK_HISTORY_FILE"), - ), ( "early_bird_pending.json", ( @@ -40,14 +36,6 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = ( "_sync_tombstones.SYNC_TOMBSTONES_FILE", ), ), - ( - "sick_day_state.json", - ( - "_constants.SICK_DAY_STATE_FILE", - "_startup_checks.SICK_DAY_STATE_FILE", - "_shutdown_sick_state.SICK_DAY_STATE_FILE", - ), - ), # The durable lock-decision trail. Written on EVERY locker run, so without # this the suite would append test decisions to the user's real # enforcement history in ~/.local/share/screen_locker/. @@ -96,7 +84,11 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = ( # Today's grace-floor lift; every shutdown add reads and writes it. ( "grace_floor.json", - ("_constants.GRACE_STATE_FILE", "_grace_floor.GRACE_STATE_FILE"), + ( + "_constants.GRACE_STATE_FILE", + "_grace_floor.GRACE_STATE_FILE", + "_sick_floor.GRACE_STATE_FILE", + ), ), # wake-alarm's workday-stick file. A test that forgot this would read # the developer's real penalty state. diff --git a/screen_locker/tests/_locker_factories.py b/screen_locker/tests/_locker_factories.py index ed0e367..babfd4f 100644 --- a/screen_locker/tests/_locker_factories.py +++ b/screen_locker/tests/_locker_factories.py @@ -44,7 +44,6 @@ def create_locker( *, demo_mode: bool = True, has_logged: bool = False, - verify_only: bool = False, is_sick_day_log: bool = False, ) -> ScreenLocker: """Create a ScreenLocker instance with early bird paths disabled.""" @@ -65,13 +64,9 @@ def create_locker( ), patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_relaxed_day_flow"), - patch.object(ScreenLocker, "_start_verify_workout_check"), patch.object(ScreenLocker, "_scan_and_fill_week_runnerup", return_value=0), ): - return ScreenLocker( - demo_mode=demo_mode, - verify_only=verify_only, - ) + return ScreenLocker(demo_mode=demo_mode) def create_locker_relaxed_day( @@ -101,7 +96,6 @@ def create_locker_relaxed_day( ), patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_relaxed_day_flow"), - patch.object(ScreenLocker, "_start_verify_workout_check"), ): return ScreenLocker(demo_mode=demo_mode) @@ -139,6 +133,5 @@ def create_locker_early_bird( patch.object(ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False), patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_relaxed_day_flow"), - patch.object(ScreenLocker, "_start_verify_workout_check"), ): return ScreenLocker(demo_mode=demo_mode) diff --git a/screen_locker/tests/_opt_in_fixtures.py b/screen_locker/tests/_opt_in_fixtures.py index d034134..ac2c64b 100644 --- a/screen_locker/tests/_opt_in_fixtures.py +++ b/screen_locker/tests/_opt_in_fixtures.py @@ -30,7 +30,7 @@ def mock_tk() -> Generator[MagicMock]: Patches the same single mock into all ``_TK_MODULES`` so assertions on ``mock_tk.Button`` capture widgets created by any of the split UI mixins - (``_ui_widgets``, ``_sick_dialog``, ...), not just ``screen_lock``. + (``_ui_widgets``, ``_manual_workout_dialog``, ...), not just ``screen_lock``. """ mock = _make_mock_tk() with ExitStack() as stack: diff --git a/screen_locker/tests/_real_state_fixtures.py b/screen_locker/tests/_real_state_fixtures.py index f3cb21c..80d7c0a 100644 --- a/screen_locker/tests/_real_state_fixtures.py +++ b/screen_locker/tests/_real_state_fixtures.py @@ -50,6 +50,25 @@ def _no_free_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ) +@pytest.fixture(autouse=True) +def _no_sick_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Never let a test read or write the developer's real sick-day pool. + + Same seam and same reason as ``_no_free_days_by_default``: sickdays binds + ``~/.local/share/sickdays`` at import time, so only its ``resolve_paths`` + can redirect it. A real sick day would otherwise stand the whole lock + chain down, and the lock screen's "I'm sick" tests would mark the + developer's real today sick. ``sync_quietly`` is stubbed so the button's + 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) + monkeypatch.setattr(sickdays, "sync_quietly", lambda **_kw: False) + + @pytest.fixture(autouse=True) def _isolate_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: """Point ``$HOME`` and ``Path.home()`` at tmp_path for every test. diff --git a/screen_locker/tests/_sick_helpers.py b/screen_locker/tests/_sick_helpers.py new file mode 100644 index 0000000..daa2406 --- /dev/null +++ b/screen_locker/tests/_sick_helpers.py @@ -0,0 +1,24 @@ +"""Mark a day sick in the test's own (tmp) sickdays pool. + +The autouse ``_no_sick_days_by_default`` fixture already points the pool at +``tmp_path``; this writes through ``sickdays.import_legacy``, the one writer +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 + +import sickdays + + +def mark_sick(day: date | str | None = None) -> date: + """Mark ``day`` (default: today, local) sick and return it.""" + if day is None: + target = sickdays.today() + elif isinstance(day, str): + target = date.fromisoformat(day) + else: + target = day + sickdays.import_legacy([target]) + return target diff --git a/screen_locker/tests/_status_view_helpers.py b/screen_locker/tests/_status_view_helpers.py index 25723be..5c97a98 100644 --- a/screen_locker/tests/_status_view_helpers.py +++ b/screen_locker/tests/_status_view_helpers.py @@ -19,7 +19,6 @@ from screen_locker._status_data import ( ManualWorkoutBudgetStatus, ShutdownProjection, ShutdownProjectionDay, - SickBudgetStatus, StatusSnapshot, WeeklySummary, ) @@ -86,19 +85,6 @@ def _lock_explanation( ) -def _sick_budget(*, used_7d: int = 0, exhausted: bool = False) -> SickBudgetStatus: - return SickBudgetStatus( - used_7d=used_7d, - budget_7d=1, - used_30d=0, - budget_30d=3, - used_90d=0, - budget_90d=10, - debt=0, - exhausted=exhausted, - ) - - def _manual_workout_budget( *, used_7d: int = 0, exhausted: bool = False ) -> ManualWorkoutBudgetStatus: @@ -138,7 +124,6 @@ def _snapshot(**overrides: object) -> StatusSnapshot: early_bird_extended=False, shutdown=_shutdown(), lock_explanation=_lock_explanation(), - sick_budget=_sick_budget(), manual_workout_budget=_manual_workout_budget(), generated_at="2024-01-05T12:00:00+00:00", ) diff --git a/screen_locker/tests/_tk_mocks.py b/screen_locker/tests/_tk_mocks.py index f003af0..cc88cb4 100644 --- a/screen_locker/tests/_tk_mocks.py +++ b/screen_locker/tests/_tk_mocks.py @@ -21,7 +21,6 @@ from unittest.mock import MagicMock # display and a ``mock_tk`` holder sees widgets made on that same mock. _TK_MODULES = ( "screen_locker.screen_lock", - "screen_locker._sick_dialog", "screen_locker._manual_workout_dialog", "screen_locker._manual_workout_sport_fields", "screen_locker._manual_workout_widgets", diff --git a/screen_locker/tests/_unlock_helpers.py b/screen_locker/tests/_unlock_helpers.py index 701374d..69d3b04 100644 --- a/screen_locker/tests/_unlock_helpers.py +++ b/screen_locker/tests/_unlock_helpers.py @@ -58,9 +58,6 @@ def setup_unlock( object.__setattr__( locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=False) ) - object.__setattr__( - locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None) - ) object.__setattr__( locker, "_adjust_shutdown_time_by", diff --git a/screen_locker/tests/test_cli.py b/screen_locker/tests/test_cli.py index 6020d56..f227326 100644 --- a/screen_locker/tests/test_cli.py +++ b/screen_locker/tests/test_cli.py @@ -24,9 +24,8 @@ class _FakeLocker: instances: ClassVar[list[_FakeLocker]] = [] - def __init__(self, *, demo_mode: bool = True, verify_only: bool = False) -> None: + def __init__(self, *, demo_mode: bool = True) -> None: self.demo_mode = demo_mode - self.verify_only = verify_only self.ran = False self.log_file: Path | None = None self.workout_data: dict[str, str] | None = None @@ -57,7 +56,7 @@ class TestHeadlessLocker: """Bypassing __init__ leaves only log_file and workout_data set.""" locker = _cli._headless_locker(_FakeLocker) - # __init__ never ran, so demo_mode/verify_only are absent entirely. + # __init__ never ran, so demo_mode is absent entirely. assert not hasattr(locker, "demo_mode") assert not _FakeLocker.instances assert not locker.workout_data @@ -160,7 +159,6 @@ class TestLockMode: locker = _FakeLocker.instances[-1] assert locker.demo_mode is True - assert locker.verify_only is False assert locker.ran is True def test_production_flag_disables_demo_mode(self) -> None: @@ -169,23 +167,11 @@ class TestLockMode: locker = _FakeLocker.instances[-1] assert locker.demo_mode is False - assert locker.verify_only is False - def test_verify_workout_flag_sets_verify_only(self) -> None: - """--verify-workout sets verify_only.""" - _cli.main(_FakeLocker, ["screen_lock.py", "--verify-workout"]) - - locker = _FakeLocker.instances[-1] - assert locker.verify_only is True - assert locker.demo_mode is True - - def test_production_and_verify_combine(self) -> None: - """--production and --verify-workout apply together.""" - _cli.main(_FakeLocker, ["screen_lock.py", "--production", "--verify-workout"]) - - locker = _FakeLocker.instances[-1] - assert locker.demo_mode is False - assert locker.verify_only is True + def test_verify_workout_flag_is_gone(self) -> None: + """The post-sick-day verify mode was removed with the old sick day.""" + assert "--verify-workout" not in _cli._MODE_FLAGS + assert "--verify-workout" not in _cli._USAGE class TestScreenLockDelegation: diff --git a/screen_locker/tests/test_compliance_state.py b/screen_locker/tests/test_compliance_state.py index f619aa7..35ad87f 100644 --- a/screen_locker/tests/test_compliance_state.py +++ b/screen_locker/tests/test_compliance_state.py @@ -15,7 +15,7 @@ from screen_locker._compliance_state import ( is_scheduled_skip_today, is_sick_day_today, ) -from screen_locker._sick_tracker import SickHistory +from screen_locker.tests._sick_helpers import mark_sick if TYPE_CHECKING: from pathlib import Path @@ -224,21 +224,19 @@ class TestIsEarlyBirdPending: class TestIsSickDayToday: - """Reading today's sick-day status out of the sick history.""" + """Today's sick-day status, from the shared sickdays pool.""" - """Thin wrapper around _sick_tracker.is_sick_day.""" + def test_true_when_in_pool(self) -> None: + """Today in the pool is a sick day.""" + mark_sick() + assert is_sick_day_today() is True - def test_true_when_listed(self) -> None: - """Today present in the history is a sick day.""" - history = SickHistory(sick_days=[_today()]) - assert is_sick_day_today(history) is True + def test_false_when_not_in_pool(self) -> None: + """Another day in the pool does not make today sick.""" + mark_sick("1999-01-01") + assert is_sick_day_today() is False - def test_false_when_not_listed(self) -> None: - """Today absent from the history is not a sick day.""" - history = SickHistory(sick_days=["1999-01-01"]) - assert is_sick_day_today(history) is False - - def test_explicit_today_override(self) -> None: - """The `today` argument overrides the real date.""" - history = SickHistory(sick_days=["2020-05-05"]) - assert is_sick_day_today(history, today="2020-05-05") is True + def test_explicit_day(self) -> None: + """The day argument picks which date is asked.""" + mark_sick("2020-05-05") + assert is_sick_day_today(date(2020, 5, 5)) is True diff --git a/screen_locker/tests/test_compliance_state_part2.py b/screen_locker/tests/test_compliance_state_part2.py index 390bdc1..373b083 100644 --- a/screen_locker/tests/test_compliance_state_part2.py +++ b/screen_locker/tests/test_compliance_state_part2.py @@ -10,7 +10,7 @@ from unittest.mock import patch import freedays from screen_locker._compliance_state import explain_lock_decision -from screen_locker._sick_tracker import SickHistory +from screen_locker.tests._sick_helpers import mark_sick if TYPE_CHECKING: from pathlib import Path @@ -37,7 +37,6 @@ class TestExplainLockDecision: ) result = explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=False, ) @@ -56,7 +55,6 @@ class TestExplainLockDecision: ): result = explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=False, wake_skip=True, @@ -78,7 +76,6 @@ class TestExplainLockDecision: ): result = explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=False, now=now, @@ -93,9 +90,9 @@ class TestExplainLockDecision: def test_sick_day_skips(self, tmp_path: Path) -> None: files = self._files(tmp_path) + mark_sick() result = explain_lock_decision( **files, - sick_history=SickHistory(sick_days=[_today()]), weekly_minimum_met=False, relaxed_day=False, now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0), @@ -111,7 +108,6 @@ class TestExplainLockDecision: ): result = explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=False, now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0), @@ -123,7 +119,6 @@ class TestExplainLockDecision: files = self._files(tmp_path) result = explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=False, wake_skip=True, @@ -137,7 +132,6 @@ class TestExplainLockDecision: files = self._files(tmp_path) result = explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=False, wake_skip=True, @@ -150,7 +144,6 @@ class TestExplainLockDecision: files = self._files(tmp_path) result = explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=True, now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0), @@ -173,7 +166,6 @@ class TestExplainLockDecision: write_signed_entry(files["log_file"], _today(), {"type": "relaxed_day_skip"}) result = explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=True, now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0), @@ -185,7 +177,6 @@ class TestExplainLockDecision: files = self._files(tmp_path) result = explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=True, relaxed_day=False, now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0), @@ -197,7 +188,6 @@ class TestExplainLockDecision: files = self._files(tmp_path) result = explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=False, now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0), @@ -212,7 +202,6 @@ class TestExplainLockDecision: files = self._files(tmp_path) result = explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=False, ) diff --git a/screen_locker/tests/test_compliance_state_part3.py b/screen_locker/tests/test_compliance_state_part3.py index f1577da..0bb91b3 100644 --- a/screen_locker/tests/test_compliance_state_part3.py +++ b/screen_locker/tests/test_compliance_state_part3.py @@ -9,36 +9,28 @@ from screen_locker._compliance_state import ( class TestDescribeAutoUpgradeOpportunity: - """The 3 possible outcomes: expired early-bird, sick day, or none.""" + """The 2 possible outcomes: expired early-bird, or none.""" def test_expired_early_bird(self) -> None: """Expired early bird.""" result = describe_auto_upgrade_opportunity( - early_bird_pending=True, early_bird_window_open=False, is_sick_day=False + early_bird_pending=True, early_bird_window_open=False ) assert result == AutoUpgradeOpportunity( would_attempt=True, via="early_bird_expired", reason=result.reason ) - def test_sick_day(self) -> None: - """Sick day.""" + def test_pending_inside_open_window_is_no_upgrade(self) -> None: + """pending+open is not expired, and a sick day never upgrades.""" result = describe_auto_upgrade_opportunity( - early_bird_pending=False, early_bird_window_open=False, is_sick_day=True + early_bird_pending=True, early_bird_window_open=True ) - assert result.would_attempt is True - assert result.via == "sick_day" - - def test_sick_day_takes_second_priority_when_pending_still_open(self) -> None: - """pending+open doesn't count as expired, so sick_day is still checked.""" - result = describe_auto_upgrade_opportunity( - early_bird_pending=True, early_bird_window_open=True, is_sick_day=True - ) - assert result.via == "sick_day" + assert result.via == "none" def test_none(self) -> None: """None.""" result = describe_auto_upgrade_opportunity( - early_bird_pending=False, early_bird_window_open=False, is_sick_day=False + early_bird_pending=False, early_bird_window_open=False ) assert result.would_attempt is False assert result.via == "none" diff --git a/screen_locker/tests/test_compliance_state_part4.py b/screen_locker/tests/test_compliance_state_part4.py index e58f510..8e97610 100644 --- a/screen_locker/tests/test_compliance_state_part4.py +++ b/screen_locker/tests/test_compliance_state_part4.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import TYPE_CHECKING from screen_locker import _compliance_state -from screen_locker._sick_tracker import SickHistory if TYPE_CHECKING: from pathlib import Path @@ -24,14 +23,12 @@ class TestEarlyBirdWindowIsTheCarrot: } open_ = _compliance_state.explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=False, wake_skip=True, ) shut = _compliance_state.explain_lock_decision( **files, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=False, wake_skip=False, diff --git a/screen_locker/tests/test_credit_slot.py b/screen_locker/tests/test_credit_slot.py index 3d448b4..a29ce88 100644 --- a/screen_locker/tests/test_credit_slot.py +++ b/screen_locker/tests/test_credit_slot.py @@ -101,9 +101,6 @@ def _wired_locker(mock_tk: MagicMock, tmp_path: Path, wtype: str) -> ScreenLocke locker, "_read_shutdown_config", MagicMock(side_effect=lambda: config["value"]) ) object.__setattr__(locker, "_write_shutdown_config", MagicMock(side_effect=write)) - object.__setattr__( - locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None) - ) return locker diff --git a/screen_locker/tests/test_early_bird_part2.py b/screen_locker/tests/test_early_bird_part2.py index e773d51..5c1295a 100644 --- a/screen_locker/tests/test_early_bird_part2.py +++ b/screen_locker/tests/test_early_bird_part2.py @@ -123,7 +123,6 @@ class TestInitEarlyBirdFlow: ), patch.object(ScreenLocker, "_save_early_bird_pending") as mock_save, patch.object(ScreenLocker, "_start_phone_check"), - patch.object(ScreenLocker, "_start_verify_workout_check"), patch( "screen_locker._early_bird.morning_skip_today", return_value=None, @@ -167,7 +166,6 @@ class TestInitEarlyBirdFlow: ScreenLocker, "_try_auto_upgrade_early_bird", return_value=True ), patch.object(ScreenLocker, "_start_phone_check"), - patch.object(ScreenLocker, "_start_verify_workout_check"), pytest.raises(SystemExit), ): ScreenLocker(demo_mode=True) diff --git a/screen_locker/tests/test_earner_units.py b/screen_locker/tests/test_earner_units.py index 48b1eed..d38ac41 100644 --- a/screen_locker/tests/test_earner_units.py +++ b/screen_locker/tests/test_earner_units.py @@ -157,4 +157,4 @@ class TestBonusLock: ): assert reset_to_base_if_new_day(state, object()) is True lock.assert_called_once_with(state) - reset.assert_called_once_with(state, reset.call_args.args[1], None, None) + reset.assert_called_once_with(state, reset.call_args.args[1], None) diff --git a/screen_locker/tests/test_focus_and_scroll.py b/screen_locker/tests/test_focus_and_scroll.py index a088f86..9f407d9 100644 --- a/screen_locker/tests/test_focus_and_scroll.py +++ b/screen_locker/tests/test_focus_and_scroll.py @@ -173,50 +173,3 @@ class TestSurfacesSettle: locker.teardown_surface(surface) assert "HDMI-1" not in locker._scrollers - - -@pytest.mark.usefixtures("mock_sys_exit") -class TestMultiLineField: - """``_add_label_text`` is the only Tab-safe multi-line field helper.""" - - def test_builds_a_label_and_a_text_box_on_every_monitor( - self, mock_tk: MagicMock, tmp_path: Path - ) -> None: - """Both halves fan out, like every other widget helper here.""" - locker = create_locker(mock_tk, tmp_path) - parent = locker.container.child_frame(bg=locker._colors.palette.bg) - mock_tk.Text.reset_mock() - - boxes = locker._add_label_text(parent, label="Why?", height=3) - - assert mock_tk.Text.call_count == len(locker.container.surfaces) - assert mock_tk.Text.call_args.kwargs["height"] == 3 - assert boxes.first is not None - - def test_the_box_is_not_a_keyboard_dead_end( - self, mock_tk: MagicMock, tmp_path: Path - ) -> None: - """Tk makes insert a tab character and refocus the same widget. - - Untreated, that traps a keyboard user inside the field with no - advertised way out -- on this lock, that means never reaching SUBMIT. - """ - locker = create_locker(mock_tk, tmp_path) - parent = locker.container.child_frame(bg=locker._colors.palette.bg) - - boxes = locker._add_label_text(parent, label="Why?") - - bound = [call.args[0] for box in boxes for call in box.bind.call_args_list] - assert "" in bound - assert "" in bound - - def test_paste_is_disabled(self, mock_tk: MagicMock, tmp_path: Path) -> None: - """The justification has to be typed, like every other field here.""" - locker = create_locker(mock_tk, tmp_path) - parent = locker.container.child_frame(bg=locker._colors.palette.bg) - - boxes = locker._add_label_text(parent, label="Why?") - - bound = [call.args[0] for box in boxes for call in box.bind.call_args_list] - assert "<>" in bound - assert "" in bound diff --git a/screen_locker/tests/test_grace_floor.py b/screen_locker/tests/test_grace_floor.py index ee0d533..1a02fab 100644 --- a/screen_locker/tests/test_grace_floor.py +++ b/screen_locker/tests/test_grace_floor.py @@ -12,16 +12,16 @@ from unittest.mock import patch import earned_time import pytest -from screen_locker import _earned, _grace_floor, _sick_tracker +from screen_locker import _earned, _grace_floor from screen_locker._grace_floor import ( GraceFloorMixin, first_done_at, grace_for, plan_absorb, save_lift, - sick_on, workout_done_at, ) +from screen_locker.tests._sick_helpers import mark_sick if TYPE_CHECKING: from pathlib import Path @@ -194,14 +194,8 @@ class TestApply: assert plan_absorb(0) == (0, 0) def test_a_sick_day_has_no_floor(self) -> None: - """The user's call: the sick-day shutdown wins.""" - _grace_floor_history([_ISO]) - assert sick_on(_DAY) + """A sick day has the 20:00 sick floor instead.""" + mark_sick(_DAY) 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 _grace_floor_history(days: list[str]) -> None: - - _sick_tracker.SICK_HISTORY_FILE.write_text(json.dumps({"sick_days": days})) diff --git a/screen_locker/tests/test_init_and_log_part2.py b/screen_locker/tests/test_init_and_log_part2.py index ac400ed..7b9942d 100644 --- a/screen_locker/tests/test_init_and_log_part2.py +++ b/screen_locker/tests/test_init_and_log_part2.py @@ -2,10 +2,8 @@ from __future__ import annotations -from datetime import UTC, datetime -import json import tkinter as tk -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest @@ -34,98 +32,28 @@ class TestRun: locker.root.mainloop.assert_called_once() -class TestAutoUpgradeSickDay: - """Tests for sick_day → phone_verified silent upgrade helpers.""" +class TestSickDayStandsDown: + """A sick day (sickdays pool) skips the lock; nothing is upgraded or logged.""" - def test_upgrade_succeeds_when_phone_verified( + def test_init_exits_on_a_sick_day_without_touching_the_phone( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: - """Verified phone workout overwrites today's sick_day entry.""" - log_file = tmp_path / "log.json" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = log_file - with ( - patch.object( - locker, - "_verify_phone_workout", - return_value=("verified", "Workout verified! (1 session)"), - ), - patch.object( - locker, - "_adjust_shutdown_time_later", - return_value=True, - ) as mock_adjust, - patch( - "screen_locker._log_mixin.compute_entry_hmac", - return_value="sig", - ), - ): - assert locker._try_auto_upgrade_sick_day() is True - mock_adjust.assert_called_once() - - today = datetime.now(tz=UTC).strftime("%Y-%m-%d") - with log_file.open() as f: - data: dict[str, Any] = json.load(f) - # The log is day-keyed with a LIST of workouts; the upgrade appends one. - assert len(data[today]) == 1 - entry = data[today][0] - assert entry["workout_data"]["type"] == "phone_verified" - assert entry["workout_data"]["after_sick_day"] == "true" - assert entry["workout_id"] == f"phone_verified:{today}" - - def test_upgrade_skipped_when_not_verified( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Non-verified statuses leave the sick_day entry untouched.""" - locker = create_locker(mock_tk, tmp_path) - with patch.object( - locker, - "_verify_phone_workout", - return_value=("no_phone", "No phone connected"), - ): - assert locker._try_auto_upgrade_sick_day() is False - assert locker.workout_data == {} - - def test_upgrade_skipped_on_exception( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Transient OSError/RuntimeError during check is non-fatal.""" - locker = create_locker(mock_tk, tmp_path) - with patch.object( - locker, - "_verify_phone_workout", - side_effect=OSError("transient"), - ): - assert locker._try_auto_upgrade_sick_day() is False - - def test_init_exits_when_sick_day_upgrade_succeeds( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Startup exits 0 after a successful silent sick_day upgrade.""" + """Startup exits 0 and never runs a phone or RunnerUp check.""" mock_sys_exit.side_effect = SystemExit(0) with ( - patch.object( - ScreenLocker, - "_try_auto_upgrade_sick_day", - return_value=True, - ) as mock_upgrade, + patch.object(ScreenLocker, "_verify_phone_workout") as phone, + patch.object(ScreenLocker, "_verify_runnerup_workout") as runnerup, + patch("screen_locker._auto_upgrade.record_decision") as record, pytest.raises(SystemExit), ): create_locker(mock_tk, tmp_path, is_sick_day_log=True) - mock_upgrade.assert_called_once() mock_sys_exit.assert_called_once_with(0) + phone.assert_not_called() + runnerup.assert_not_called() + assert record.call_args.args[0].reason == "sick_day" class TestMainEntry: diff --git a/screen_locker/tests/test_local_day_key.py b/screen_locker/tests/test_local_day_key.py index 18e826d..5758017 100644 --- a/screen_locker/tests/test_local_day_key.py +++ b/screen_locker/tests/test_local_day_key.py @@ -27,7 +27,6 @@ from screen_locker._compliance_predicates import has_logged_today from screen_locker._compliance_state import explain_lock_decision from screen_locker._log_io import load_workout_log from screen_locker._log_mixin import write_signed_entry -from screen_locker._sick_tracker import SickHistory if TYPE_CHECKING: from collections.abc import Iterator @@ -100,7 +99,6 @@ class TestAfterMidnightLog: result = explain_lock_decision( log_file=log_file, early_bird_pending_file=tmp_path / "early_bird_pending.json", - sick_history=SickHistory(), weekly_minimum_met=True, relaxed_day=False, now=JUST_AFTER_MIDNIGHT_UTC, diff --git a/screen_locker/tests/test_manual_sync_part3.py b/screen_locker/tests/test_manual_sync_part3.py index 4e419b2..52ced1a 100644 --- a/screen_locker/tests/test_manual_sync_part3.py +++ b/screen_locker/tests/test_manual_sync_part3.py @@ -62,7 +62,7 @@ _OTHER_DRAFT = ManualWorkoutDraft( class TestIngestManualRecordsCallback: - """Tests for the ``on_ingested`` callback that drives shutdown/debt credit.""" + """Tests for the ``on_ingested`` callback that drives shutdown credit.""" def test_on_ingested_called_with_entry_and_prior_entries( self, tmp_path: Path diff --git a/screen_locker/tests/test_manual_workout_dialog.py b/screen_locker/tests/test_manual_workout_dialog.py index cdf3e16..b3ac79b 100644 --- a/screen_locker/tests/test_manual_workout_dialog.py +++ b/screen_locker/tests/test_manual_workout_dialog.py @@ -240,11 +240,3 @@ class TestInitialKeyboardFocus: locker._show_manual_workout_form() focused = mock_tk.Entry.return_value.focus_set.call_count assert focused == 1, f"expected exactly one focused entry, got {focused}" - - def test_sick_justification_form_focuses_one_entry( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - locker._build_justification_form(had_commitment=False) - focused = mock_tk.Entry.return_value.focus_set.call_count - assert focused == 1, f"expected exactly one focused entry, got {focused}" diff --git a/screen_locker/tests/test_manual_workout_features.py b/screen_locker/tests/test_manual_workout_features.py index 481c6fb..c5f6353 100644 --- a/screen_locker/tests/test_manual_workout_features.py +++ b/screen_locker/tests/test_manual_workout_features.py @@ -7,8 +7,7 @@ import pathlib from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from screen_locker import _manual_workout, _sick_tracker -from screen_locker._sick_tracker import SickHistory +from screen_locker import _manual_workout from screen_locker.tests.conftest import create_locker # The Dart mirror of the budget caps; read at test time so drift on @@ -35,7 +34,6 @@ class TestShowRetryAndManualWorkoutBudget: """Shows button when budget available.""" locker = create_locker(mock_tk, tmp_path) with ( - patch.object(_sick_tracker, "load_history", return_value=SickHistory()), patch.object(_manual_workout, "is_budget_exhausted", return_value=False), ): locker._show_retry_and_sick("nope") @@ -48,7 +46,6 @@ class TestShowRetryAndManualWorkoutBudget: """Hides button when budget exhausted.""" locker = create_locker(mock_tk, tmp_path) with ( - patch.object(_sick_tracker, "load_history", return_value=SickHistory()), patch.object(_manual_workout, "is_budget_exhausted", return_value=True), ): locker._show_retry_and_sick("nope") diff --git a/screen_locker/tests/test_manual_workout_features_part2.py b/screen_locker/tests/test_manual_workout_features_part2.py index db12f08..bf1c6f6 100644 --- a/screen_locker/tests/test_manual_workout_features_part2.py +++ b/screen_locker/tests/test_manual_workout_features_part2.py @@ -50,18 +50,13 @@ class TestApplyWorkoutCredit: object.__setattr__( locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True) ) - object.__setattr__( - locker, "_clear_debt_on_verified_workout", MagicMock(return_value=0) - ) result = locker._apply_workout_credit() assert result.already_counted_today is True assert result.shutdown_adjusted is False - assert result.new_debt is None assert result.extra_bonus_delta == 0 locker._try_adjust_shutdown_for_workout.assert_not_called() - locker._clear_debt_on_verified_workout.assert_not_called() locker.save_workout_log.assert_called_once() # log entry write attempted def test_first_workout_of_day_gets_base_shutdown_push( @@ -78,15 +73,11 @@ class TestApplyWorkoutCredit: object.__setattr__( locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True) ) - object.__setattr__( - locker, "_clear_debt_on_verified_workout", MagicMock(return_value=2) - ) result = locker._apply_workout_credit() assert result.already_counted_today is False assert result.shutdown_adjusted is True - assert result.new_debt == 2 assert result.extra_bonus_delta == 0 locker._try_adjust_shutdown_for_workout.assert_called_once() @@ -121,9 +112,6 @@ class TestApplyWorkoutCredit: object.__setattr__( locker, "_adjust_shutdown_time_by", MagicMock(return_value=True) ) - object.__setattr__( - locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None) - ) with patch( "screen_locker._workout_credit.count_weekly_workouts", return_value=5 ): @@ -165,9 +153,6 @@ class TestApplyWorkoutCredit: object.__setattr__( locker, "_adjust_shutdown_time_by", MagicMock(return_value=True) ) - object.__setattr__( - locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None) - ) with patch( "screen_locker._workout_credit.count_weekly_workouts", return_value=5 ): diff --git a/screen_locker/tests/test_manual_workout_features_part4.py b/screen_locker/tests/test_manual_workout_features_part4.py index b6e1e88..e6a20e9 100644 --- a/screen_locker/tests/test_manual_workout_features_part4.py +++ b/screen_locker/tests/test_manual_workout_features_part4.py @@ -1,4 +1,4 @@ -"""More workout-credit cases: bonuses, unreadable config, sick days. +"""More workout-credit cases: bonuses and an unreadable config. Split out of test_manual_workout_features_part2.py for the 250-line cap. """ @@ -54,9 +54,6 @@ class TestApplyWorkoutCredit: object.__setattr__( locker, "_adjust_shutdown_time_by", MagicMock(return_value=True) ) - object.__setattr__( - locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None) - ) result = locker._apply_workout_credit() @@ -84,9 +81,6 @@ class TestApplyWorkoutCredit: object.__setattr__( locker, "_adjust_shutdown_time_by", MagicMock(return_value=True) ) - object.__setattr__( - locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None) - ) with patch( "screen_locker._workout_credit._credit_notify.notify_workout_credit" @@ -98,23 +92,3 @@ class TestApplyWorkoutCredit: locker._try_adjust_shutdown_for_workout.assert_not_called() locker._adjust_shutdown_time_by.assert_not_called() notify.assert_not_called() - - def test_skips_save_for_sick_day_type( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Skips save for sick day type.""" - locker = create_locker(mock_tk, tmp_path) - locker.workout_data = {"type": "sick_day"} - object.__setattr__( - locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=False) - ) - object.__setattr__( - locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None) - ) - object.__setattr__(locker, "save_workout_log", MagicMock()) - - result = locker._apply_workout_credit() - - assert result.already_counted_today is False - assert result.shutdown_adjusted is False - locker.save_workout_log.assert_not_called() diff --git a/screen_locker/tests/test_manual_workout_status_view.py b/screen_locker/tests/test_manual_workout_status_view.py index 1616005..8883c5c 100644 --- a/screen_locker/tests/test_manual_workout_status_view.py +++ b/screen_locker/tests/test_manual_workout_status_view.py @@ -77,7 +77,6 @@ class TestOnManualWorkoutSaved: ) -> None: credit = WorkoutCreditResult( shutdown_adjusted=True, - new_debt=None, extra_bonus_delta=0, weekly_count=2, already_counted_today=False, @@ -100,12 +99,11 @@ class TestOnManualWorkoutSaved: assert "table tennis at Solec" in message assert "Shutdown time +2h later!" in message - def test_shows_debt_and_extra_bonus_lines( + def test_shows_extra_bonus_line_and_no_debt( self, mock_tk: MagicMock, temp_log_file: Path ) -> None: credit = WorkoutCreditResult( shutdown_adjusted=False, - new_debt=1, extra_bonus_delta=60, weekly_count=5, already_counted_today=False, @@ -123,14 +121,13 @@ class TestOnManualWorkoutSaved: message = window._credit_message assert message is not None assert "Extra workout today! +1h tonight" in message - assert "Workout debt: 1" in message + assert "debt" not in message.lower() def test_already_counted_today_shows_no_extra_credit_note( self, mock_tk: MagicMock, temp_log_file: Path ) -> None: credit = WorkoutCreditResult( shutdown_adjusted=False, - new_debt=None, extra_bonus_delta=0, weekly_count=3, already_counted_today=True, diff --git a/screen_locker/tests/test_mcp.py b/screen_locker/tests/test_mcp.py index a878d8f..7c62f3e 100644 --- a/screen_locker/tests/test_mcp.py +++ b/screen_locker/tests/test_mcp.py @@ -24,7 +24,6 @@ from screen_locker._status_data import ( ManualWorkoutBudgetStatus, ShutdownProjection, ShutdownProjectionDay, - SickBudgetStatus, StatusSnapshot, WeeklySummary, ) @@ -71,16 +70,6 @@ def _snapshot() -> StatusSnapshot: explanation="explanation text", ), lock_explanation=_lock_explanation(), - sick_budget=SickBudgetStatus( - used_7d=0, - budget_7d=1, - used_30d=0, - budget_30d=3, - used_90d=0, - budget_90d=10, - debt=0, - exhausted=False, - ), manual_workout_budget=ManualWorkoutBudgetStatus( used_7d=0, budget_7d=2, used_30d=0, budget_30d=5, exhausted=False ), @@ -121,7 +110,6 @@ class TestReadTools: class TestGetFlags: def test_flags_dict_and_predicate_wiring(self) -> None: with ( - patch.object(_mcp, "load_history", return_value="HIST") as lh, patch.object(_mcp, "has_logged_today", return_value=True) as hlt, patch.object(_mcp, "is_scheduled_skip_today", return_value=False) as iss, patch.object(_mcp, "is_early_bird_pending", return_value=True) as iep, @@ -134,11 +122,10 @@ class TestGetFlags: "is_early_bird_pending": True, "is_sick_day_today": False, } - lh.assert_called_once_with() hlt.assert_called_once_with(_mcp._LOG_FILE) iss.assert_called_once_with() iep.assert_called_once_with(_mcp.EARLY_BIRD_PENDING_FILE) - isd.assert_called_once_with("HIST") + isd.assert_called_once_with() def test_main_runs_stdio_server() -> None: diff --git a/screen_locker/tests/test_poke_credit.py b/screen_locker/tests/test_poke_credit.py index e907a28..c3d4ba4 100644 --- a/screen_locker/tests/test_poke_credit.py +++ b/screen_locker/tests/test_poke_credit.py @@ -41,7 +41,6 @@ def _session(**overrides: object) -> dict[str, Any]: def _result(*, counted: bool = False) -> WorkoutCreditResult: return WorkoutCreditResult( shutdown_adjusted=not counted, - new_debt=None, extra_bonus_delta=0, weekly_count=1, already_counted_today=counted, diff --git a/screen_locker/tests/test_poke_credit_day.py b/screen_locker/tests/test_poke_credit_day.py index 546ab84..28d12ef 100644 --- a/screen_locker/tests/test_poke_credit_day.py +++ b/screen_locker/tests/test_poke_credit_day.py @@ -39,7 +39,6 @@ def credit(monkeypatch: pytest.MonkeyPatch) -> MagicMock: stub = MagicMock( return_value=WorkoutCreditResult( shutdown_adjusted=True, - new_debt=None, extra_bonus_delta=0, weekly_count=1, already_counted_today=False, diff --git a/screen_locker/tests/test_runnerup_backfill_credit.py b/screen_locker/tests/test_runnerup_backfill_credit.py index 3095a0b..eebe690 100644 --- a/screen_locker/tests/test_runnerup_backfill_credit.py +++ b/screen_locker/tests/test_runnerup_backfill_credit.py @@ -148,11 +148,7 @@ class TestSyncPassCreditsRunnerUp: locker, "_write_shutdown_config", MagicMock(side_effect=write) ) - with ( - patch("screen_locker._log_mixin.compute_entry_hmac", return_value="sig"), - patch("screen_locker._workout_credit._sick_tracker.load_history") as hist, - ): - hist.return_value.debt = 0 + with patch("screen_locker._log_mixin.compute_entry_hmac", return_value="sig"): locker._auto_fill_week_runnerup_bonus() locker._auto_fill_week_runnerup_bonus() diff --git a/screen_locker/tests/test_scheduled_skip.py b/screen_locker/tests/test_scheduled_skip.py index 049cebf..766620d 100644 --- a/screen_locker/tests/test_scheduled_skip.py +++ b/screen_locker/tests/test_scheduled_skip.py @@ -140,22 +140,3 @@ class TestScheduledSkipEarlyExit: create_locker(mock_tk, tmp_path, has_logged=False) mock_sys_exit.assert_called_once_with(0) - - def test_verify_only_mode_ignores_scheduled_skip( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """verify_only mode does not consult scheduled skips.""" - self._write_today_skip(tmp_path) - - # verify_only exits because no sick day log, not because of scheduled skip - create_locker( - mock_tk, - tmp_path, - verify_only=True, - is_sick_day_log=False, - ) - - mock_sys_exit.assert_called_once_with(0) diff --git a/screen_locker/tests/test_screen_lock_coverage_part3.py b/screen_locker/tests/test_screen_lock_coverage_part3.py index d98061a..380029f 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part3.py +++ b/screen_locker/tests/test_screen_lock_coverage_part3.py @@ -13,83 +13,6 @@ if TYPE_CHECKING: from pathlib import Path -class TestTryAutoUpgradeSickDayRunnerUp: - """Tests for RunnerUp paths in _try_auto_upgrade_sick_day (lines 273-286).""" - - def test_runnerup_exception_returns_false( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """OSError from _verify_runnerup_workout → returns False (lines 273-275).""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_verify_phone_workout", - MagicMock(return_value=("no_phone", "no phone")), - ) - object.__setattr__( - locker, - "_verify_runnerup_workout", - MagicMock(side_effect=OSError("adb fail")), - ) - assert locker._try_auto_upgrade_sick_day() is False - - def test_runnerup_verified_saves_entry( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """RunnerUp returns verified → saves runnerup_verified entry (lines 281-286).""" - log_file = tmp_path / "log.json" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = log_file - locker.workout_data = {} - object.__setattr__( - locker, - "_verify_phone_workout", - MagicMock(return_value=("no_phone", "no phone")), - ) - object.__setattr__( - locker, - "_verify_runnerup_workout", - MagicMock(return_value=("verified", "Running: 6 km in 40 min")), - ) - object.__setattr__( - locker, "_adjust_shutdown_time_later", MagicMock(return_value=True) - ) - with patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None): - result = locker._try_auto_upgrade_sick_day() - - assert result is True - today = datetime.now(tz=UTC).strftime("%Y-%m-%d") - data = json.loads(log_file.read_text()) - assert data[today][0]["workout_data"]["type"] == "runnerup_verified" - assert data[today][0]["workout_data"]["after_sick_day"] == "true" - - def test_runnerup_not_verified_returns_false( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """RunnerUp not verified → returns False.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_verify_phone_workout", - MagicMock(return_value=("no_phone", "no phone")), - ) - object.__setattr__( - locker, - "_verify_runnerup_workout", - MagicMock(return_value=("not_verified", "no run")), - ) - assert locker._try_auto_upgrade_sick_day() is False - - class TestTryAutoUpgradeEarlyBirdRunnerUp: """Tests for RunnerUp paths in screen_lock.py _try_auto_upgrade_early_bird (305-318).""" diff --git a/screen_locker/tests/test_shutdown_base.py b/screen_locker/tests/test_shutdown_base.py index c5ab770..5112052 100644 --- a/screen_locker/tests/test_shutdown_base.py +++ b/screen_locker/tests/test_shutdown_base.py @@ -121,26 +121,6 @@ class TestResetToBaseIfNewDay: base, base, 300, restore=True ) - def test_clears_sick_day_state_file_on_reset(self, tmp_path: Path) -> None: - """Existing sick-day file is deleted during reset (lines 79-82).""" - f = tmp_path / "state.json" - f.write_text(json.dumps({"last_reset_date": "2000-01-01"})) - sick_file = tmp_path / "sick.json" - sick_file.write_text("{}") - mixin = self._make_mixin() - assert reset_to_base_if_new_day(f, mixin, sick_day_state_file=sick_file) is True - assert not sick_file.exists() - - def test_handles_oserror_on_sick_file_unlink(self, tmp_path: Path) -> None: - """OSError when removing sick-day file is logged but reset still returns True (lines 83-86).""" - f = tmp_path / "state.json" - f.write_text(json.dumps({"last_reset_date": "2000-01-01"})) - sick_mock = MagicMock() - sick_mock.exists.return_value = True - sick_mock.unlink.side_effect = OSError("busy") - mixin = self._make_mixin() - assert reset_to_base_if_new_day(f, mixin, sick_day_state_file=sick_mock) is True - def test_handles_corrupt_state_file_gracefully(self, tmp_path: Path) -> None: """Corrupt state file treated as no date → reset runs (lines 64-65 except branch).""" f = tmp_path / "state.json" @@ -161,21 +141,3 @@ class TestResetToBaseIfNewDay: "screen_locker._shutdown_base.bonus_lock", return_value=nullcontext() ): assert reset_to_base_if_new_day(state_mock, mixin) is True - - def test_sick_day_state_file_not_deleted_when_absent(self, tmp_path: Path) -> None: - """No sick-day file passed → branch skipped, no AttributeError (line 79 branch False).""" - f = tmp_path / "state.json" - f.write_text(json.dumps({"last_reset_date": "2000-01-01"})) - mixin = self._make_mixin() - assert reset_to_base_if_new_day(f, mixin, sick_day_state_file=None) is True - - def test_sick_day_file_not_deleted_when_it_doesnt_exist( - self, tmp_path: Path - ) -> None: - """sick_day_state_file passed but doesn't exist → .unlink() not called (line 79 .exists() False).""" - f = tmp_path / "state.json" - f.write_text(json.dumps({"last_reset_date": "2000-01-01"})) - sick_file = tmp_path / "nonexistent_sick.json" - mixin = self._make_mixin() - assert reset_to_base_if_new_day(f, mixin, sick_day_state_file=sick_file) is True - assert not sick_file.exists() diff --git a/screen_locker/tests/test_shutdown_ceiling_invariant.py b/screen_locker/tests/test_shutdown_ceiling_invariant.py index 546197b..e90895f 100644 --- a/screen_locker/tests/test_shutdown_ceiling_invariant.py +++ b/screen_locker/tests/test_shutdown_ceiling_invariant.py @@ -179,7 +179,7 @@ def frozen_day() -> Iterator[Callable[[date], Any]]: def at_day(day: date) -> Any: iso = day.isoformat() return patch.multiple(_shutdown, today_str=lambda: iso), patch.multiple( - _grace_floor, today_str=lambda: iso, sick_on=lambda _d: False + _grace_floor, today_str=lambda: iso, is_sick=lambda _d: False ) return at_day diff --git a/screen_locker/tests/test_shutdown_minutes.py b/screen_locker/tests/test_shutdown_minutes.py index e40cff6..09f1ba7 100644 --- a/screen_locker/tests/test_shutdown_minutes.py +++ b/screen_locker/tests/test_shutdown_minutes.py @@ -9,7 +9,6 @@ as ``HH:MM`` rather than being floored. from __future__ import annotations -import json from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -65,45 +64,6 @@ class TestReadShutdownConfigKeys: assert self._read(tmp_path, body) == (1260, 1200, 300) -class TestLegacySickDayState: - """A state file written before the migration still restores correctly.""" - - def _load( - self, tmp_path: Path, state: dict[str, object] - ) -> tuple[str, int, int] | None: - locker = create_locker(MagicMock(), tmp_path) - state_file = tmp_path / "sick.json" - state_file.write_text(json.dumps(state)) - with patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", state_file - ): - return locker._load_sick_day_state() - - def test_legacy_hour_keys_are_read_as_minutes( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - state = {"date": "2026-10-03", "original_mon_wed_hour": 21} - state["original_thu_sun_hour"] = 20 - assert self._load(tmp_path, state) == ("2026-10-03", 1260, 1200) - - def test_minutes_keys_win_over_legacy_keys( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - state = { - "date": "2026-10-03", - "original_mon_wed_minutes": 1290, - "original_mon_wed_hour": 21, - "original_thu_sun_minutes": 1200, - } - assert self._load(tmp_path, state) == ("2026-10-03", 1290, 1200) - - def test_a_missing_band_is_none( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - state = {"date": "2026-10-03", "original_mon_wed_hour": 21} - assert self._load(tmp_path, state) is None - - @pytest.mark.usefixtures("pre_ladder") class TestMinutesReachTheHelperScript: """The helper receives HH:MM, so a half hour is not floored on the way.""" @@ -162,30 +122,3 @@ class TestMinutesReachTheHelperScript: ) -> None: args = self._run(mock_tk, tmp_path, config, "_adjust_shutdown_time_later") assert args == expected - - @pytest.mark.parametrize( - ("config", "expected"), - [ - ((1290, 1290, 300), ["20:30", "20:30", "05:00"]), - ((1100, 1085, 300), ["18:00", "18:00", "05:00"]), - ], - ) - def test_sick_day_steps_back_an_hour_but_not_before_18_00( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - config: tuple[int, int, int], - expected: list[str], - ) -> None: - locker = create_locker(mock_tk, tmp_path) - script = tmp_path / "adjust.sh" - script.write_text("") - with ( - patch.object(locker, "_read_shutdown_config", return_value=config), - patch.object(locker, "_save_sick_day_state", return_value=True), - patch("screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT", script), - patch("screen_locker._shutdown.subprocess.run") as run, - ): - assert locker._apply_earlier_shutdown("2026-10-04") is True - assert run.call_args.args[0][2:] == expected diff --git a/screen_locker/tests/test_shutdown_part2.py b/screen_locker/tests/test_shutdown_part2.py index 498c2e4..1e4d1e4 100644 --- a/screen_locker/tests/test_shutdown_part2.py +++ b/screen_locker/tests/test_shutdown_part2.py @@ -13,149 +13,6 @@ if TYPE_CHECKING: from pathlib import Path -class TestApplyEarlierShutdown: - """Tests for _apply_earlier_shutdown method.""" - - def test_returns_false_when_no_config( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False when config can't be read.""" - locker = create_locker(mock_tk, tmp_path) - with patch.object(locker, "_read_shutdown_config", return_value=None): - assert locker._apply_earlier_shutdown("2026-03-21") is False - - def test_returns_false_when_save_state_fails( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False when saving state fails.""" - locker = create_locker(mock_tk, tmp_path) - with ( - patch.object( - locker, "_read_shutdown_config", return_value=(1260, 1200, 480) - ), - patch.object(locker, "_save_sick_day_state", return_value=False), - ): - assert locker._apply_earlier_shutdown("2026-03-21") is False - - def test_success_applies_earlier_hours( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test successful application of earlier shutdown hours.""" - locker = create_locker(mock_tk, tmp_path) - with ( - patch.object( - locker, "_read_shutdown_config", return_value=(1260, 1200, 480) - ), - patch.object(locker, "_save_sick_day_state", return_value=True), - patch.object( - locker, "_write_shutdown_config", return_value=True - ) as mock_write, - ): - result = locker._apply_earlier_shutdown("2026-03-21") - assert result is True - mock_write.assert_called_once_with(1200, 1140, 480) - - def test_clamps_to_minimum_18( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test hours are clamped to minimum of 18.""" - locker = create_locker(mock_tk, tmp_path) - with ( - patch.object( - locker, "_read_shutdown_config", return_value=(1080, 1080, 480) - ), - patch.object(locker, "_save_sick_day_state", return_value=True), - patch.object( - locker, "_write_shutdown_config", return_value=True - ) as mock_write, - ): - locker._apply_earlier_shutdown("2026-03-21") - mock_write.assert_called_once_with(1080, 1080, 480) - - -class TestAdjustShutdownTimeEarlier: - """Tests for _adjust_shutdown_time_earlier method.""" - - def test_returns_false_when_sick_mode_used_today( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False when sick mode already used today.""" - locker = create_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_restore_original_config_if_needed"), - patch.object(locker, "_sick_mode_used_today", return_value=True), - ): - assert locker._adjust_shutdown_time_earlier() is False - - def test_success( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test successful adjustment.""" - locker = create_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_restore_original_config_if_needed"), - patch.object(locker, "_sick_mode_used_today", return_value=False), - patch.object(locker, "_apply_earlier_shutdown", return_value=True), - ): - assert locker._adjust_shutdown_time_earlier() is True - - def test_handles_oserror( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test handles OSError during apply.""" - locker = create_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_restore_original_config_if_needed"), - patch.object(locker, "_sick_mode_used_today", return_value=False), - patch.object( - locker, - "_apply_earlier_shutdown", - side_effect=OSError("fail"), - ), - ): - assert locker._adjust_shutdown_time_earlier() is False - - def test_handles_value_error( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test handles ValueError during apply.""" - locker = create_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_restore_original_config_if_needed"), - patch.object(locker, "_sick_mode_used_today", return_value=False), - patch.object( - locker, - "_apply_earlier_shutdown", - side_effect=ValueError("bad"), - ), - ): - assert locker._adjust_shutdown_time_earlier() is False - - @pytest.mark.usefixtures("pre_ladder") class TestAdjustShutdownTimeLater: """Tests for _adjust_shutdown_time_later method.""" diff --git a/screen_locker/tests/test_shutdown_part3.py b/screen_locker/tests/test_shutdown_part3.py index d417002..a90e0a6 100644 --- a/screen_locker/tests/test_shutdown_part3.py +++ b/screen_locker/tests/test_shutdown_part3.py @@ -2,8 +2,6 @@ from __future__ import annotations -from datetime import UTC -import json from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -14,138 +12,6 @@ if TYPE_CHECKING: from pathlib import Path -class TestRestoreOriginalConfigIfNeeded: - """Tests for _restore_original_config_if_needed method.""" - - def test_no_state_file_does_nothing( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test does nothing when no state file exists.""" - locker = create_locker(mock_tk, tmp_path) - mock_file = MagicMock() - mock_file.exists.return_value = False - with patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - mock_file, - ): - locker._restore_original_config_if_needed() - - def test_restores_when_state_from_previous_day( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test restores config when state date differs from today.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - state_file.write_text( - json.dumps( - { - "date": "2020-01-01", - "original_mon_wed_minutes": 1260, - "original_thu_sun_minutes": 1200, - } - ) - ) - with ( - patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - state_file, - ), - patch.object(locker, "_write_restored_config") as mock_restore, - ): - locker._restore_original_config_if_needed() - mock_restore.assert_called_once_with(1260, 1200, "2020-01-01") - - def test_does_not_restore_when_state_from_today( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test does not restore when state date matches today.""" - locker = create_locker(mock_tk, tmp_path) - from datetime import datetime - - today = datetime.now(tz=UTC).strftime("%Y-%m-%d") - state_file = tmp_path / "state.json" - state_file.write_text( - json.dumps( - { - "date": today, - "original_mon_wed_minutes": 1260, - "original_thu_sun_minutes": 1200, - } - ) - ) - with ( - patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - state_file, - ), - patch.object(locker, "_write_restored_config") as mock_restore, - ): - locker._restore_original_config_if_needed() - mock_restore.assert_not_called() - - def test_returns_when_loaded_state_is_none( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns early when loaded state is None.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - state_file.write_text(json.dumps({"date": "2020-01-01"})) - with ( - patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - state_file, - ), - patch.object(locker, "_write_restored_config") as mock_restore, - ): - locker._restore_original_config_if_needed() - mock_restore.assert_not_called() - - def test_handles_oserror( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test handles OSError when loading state.""" - locker = create_locker(mock_tk, tmp_path) - mock_file = MagicMock() - mock_file.exists.return_value = True - mock_file.open.side_effect = OSError("fail") - with patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - mock_file, - ): - locker._restore_original_config_if_needed() - - def test_handles_json_decode_error( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test handles JSONDecodeError when loading state.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - state_file.write_text("not valid json{{{") - with patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - state_file, - ): - locker._restore_original_config_if_needed() - - class TestReadShutdownConfig: """Tests for _read_shutdown_config method.""" diff --git a/screen_locker/tests/test_shutdown_part4.py b/screen_locker/tests/test_shutdown_part4.py index 26cc2db..87b3f02 100644 --- a/screen_locker/tests/test_shutdown_part4.py +++ b/screen_locker/tests/test_shutdown_part4.py @@ -2,9 +2,8 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from screen_locker.tests.conftest import create_locker @@ -12,143 +11,6 @@ if TYPE_CHECKING: from pathlib import Path -class TestSaveSickDayState: - """Tests for _save_sick_day_state method.""" - - def test_saves_state_successfully( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test saves state file with correct content.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - with patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - state_file, - ): - result = locker._save_sick_day_state("2026-03-21", 1260, 1200) - assert result is True - data = json.loads(state_file.read_text()) - assert data["date"] == "2026-03-21" - assert data["original_mon_wed_minutes"] == 1260 - assert data["original_thu_sun_minutes"] == 1200 - - def test_returns_false_on_oserror( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False when write fails.""" - locker = create_locker(mock_tk, tmp_path) - mock_path = MagicMock() - mock_path.open.side_effect = OSError("permission denied") - with patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - mock_path, - ): - result = locker._save_sick_day_state("2026-03-21", 1260, 1200) - assert result is False - - -class TestLoadSickDayState: - """Tests for _load_sick_day_state method.""" - - def test_loads_valid_state( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test loads state with all fields present.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - state_file.write_text( - json.dumps( - { - "date": "2026-03-20", - "original_mon_wed_minutes": 1260, - "original_thu_sun_minutes": 1200, - } - ) - ) - with patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - state_file, - ): - result = locker._load_sick_day_state() - assert result == ("2026-03-20", 1260, 1200) - - def test_returns_none_when_fields_missing( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns None when required fields are missing.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - state_file.write_text(json.dumps({"date": "2026-03-20"})) - with patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - state_file, - ): - result = locker._load_sick_day_state() - assert result is None - - -class TestWriteRestoredConfig: - """Tests for _write_restored_config method.""" - - def test_restores_config_and_removes_state( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test restores config values and deletes state file.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - state_file.write_text("{}") - with ( - patch.object( - locker, "_read_shutdown_config", return_value=(1200, 1140, 480) - ), - patch.object( - locker, "_write_shutdown_config", return_value=True - ) as mock_write, - patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - state_file, - ), - ): - locker._write_restored_config(1260, 1200, "2026-03-20") - mock_write.assert_called_once_with(1260, 1200, 480, restore=True) - assert not state_file.exists() - - def test_still_removes_state_when_config_read_fails( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test removes state file even when config read returns None.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - state_file.write_text("{}") - with ( - patch.object(locker, "_read_shutdown_config", return_value=None), - patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - state_file, - ), - ): - locker._write_restored_config(1260, 1200, "2026-03-20") - assert not state_file.exists() - - class TestAdjustShutdownTimeBy: """Tests for _adjust_shutdown_time_by method (extra-workout bonus).""" diff --git a/screen_locker/tests/test_shutdown_part5.py b/screen_locker/tests/test_shutdown_part5.py deleted file mode 100644 index a94d577..0000000 --- a/screen_locker/tests/test_shutdown_part5.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Tests for shutdown schedule adjustment coverage gaps (part 2).""" - -from __future__ import annotations - -from datetime import UTC -import json -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from screen_locker.tests.conftest import create_locker - -if TYPE_CHECKING: - from pathlib import Path - - -class TestSickModeUsedToday: - """Tests for _sick_mode_used_today method.""" - - def test_returns_false_when_no_file( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False when state file doesn't exist.""" - locker = create_locker(mock_tk, tmp_path) - mock_file = MagicMock() - mock_file.exists.return_value = False - with patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - mock_file, - ): - assert locker._sick_mode_used_today() is False - - def test_returns_true_when_used_today( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns True when state matches today.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - with patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - state_file, - ): - from datetime import datetime - - today = datetime.now(tz=UTC).strftime("%Y-%m-%d") - state_file.write_text(json.dumps({"date": today})) - assert locker._sick_mode_used_today() is True - - def test_returns_false_when_different_date( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False when state is from different date.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - with patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - state_file, - ): - state_file.write_text(json.dumps({"date": "2020-01-01"})) - assert locker._sick_mode_used_today() is False - - def test_returns_false_on_json_error( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False on JSONDecodeError.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - with patch( - "screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", - state_file, - ): - state_file.write_text("not json{{{") - assert locker._sick_mode_used_today() is False diff --git a/screen_locker/tests/test_shutdown_target.py b/screen_locker/tests/test_shutdown_target.py index 5c3f291..f0f056e 100644 --- a/screen_locker/tests/test_shutdown_target.py +++ b/screen_locker/tests/test_shutdown_target.py @@ -7,6 +7,8 @@ import json from typing import TYPE_CHECKING from unittest.mock import patch +import earned_time + from screen_locker import _grace_floor from screen_locker._shutdown_target import ( DayInputs, @@ -21,6 +23,7 @@ if TYPE_CHECKING: _DAY = date(2026, 10, 10) _ISO = _DAY.isoformat() +SICK_FLOOR = earned_time.SICK_SHUTDOWN_FLOOR_MINUTES _FLAT = {"leetcode": False, "reading": False, "anki": False, "tutor": False} @@ -48,10 +51,26 @@ class TestTarget: assert target.minutes == max(target.earned, 19 * 60 + 55) assert target.lift == target.minutes - target.earned - def test_a_sick_day_drops_the_floor(self) -> None: + def test_a_sick_day_has_no_grace_floor_but_the_20_00_one(self) -> None: target = derive(DayInputs(_DAY, _FLAT, first_done=at("18:55"), sick_day=True)) assert target.grace is None + assert target.resolution.sick is True + assert target.minutes == max(SICK_FLOOR, target.earned) + # The lift is the sick floor's, so later credits absorb it first. + assert target.lift == target.resolution.sick_lift_minutes + assert target.lift == SICK_FLOOR - target.earned + + def test_a_sick_day_above_20_00_keeps_the_ladder_value(self) -> None: + busy = dict.fromkeys(_FLAT, True) + target = derive(DayInputs(_DAY, busy, workout_credits=1, sick_day=True)) assert target.minutes == target.earned + assert target.minutes > SICK_FLOOR + assert target.lift == 0 + + def test_not_sick_is_the_plain_sum(self) -> None: + target = derive(DayInputs(_DAY, _FLAT)) + assert target.resolution.sick is False + assert target.minutes == target.earned == target.resolution.shutdown_minutes def test_no_log_counts_no_workouts(self) -> None: assert day_credit_count(None, _DAY) == 0 diff --git a/screen_locker/tests/test_sick_button.py b/screen_locker/tests/test_sick_button.py new file mode 100644 index 0000000..34a0fdf --- /dev/null +++ b/screen_locker/tests/test_sick_button.py @@ -0,0 +1,139 @@ +"""The one-tap "I'm sick" button and the sickdays seam behind it.""" + +from __future__ import annotations + +from datetime import date, timedelta +import threading +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import freedays +import sickdays + +from screen_locker import _sick, _ui_flows_sick +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 + +if TYPE_CHECKING: + from pathlib import Path + + +def _button_texts(mock_tk: MagicMock) -> list[str]: + return [str(c.kwargs.get("text", "")) for c in mock_tk.Button.call_args_list] + + +def _label_texts(mock_tk: MagicMock) -> str: + return " ".join(str(c.kwargs.get("text", "")) for c in mock_tk.Label.call_args_list) + + +class TestSeam: + def test_is_sick_and_sick_dates_read_the_pool(self) -> None: + assert _sick.is_sick() is False + day = mark_sick() + old = mark_sick("2026-06-21") + assert _sick.is_sick() is True + assert _sick.is_sick(day - timedelta(days=1)) is False + assert _sick.sick_dates() >= {day, old} + + 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_already_sick_is_success(self) -> None: + mark_sick() + result = _sick.mark_today_sick() + assert result.ok is True + assert "already" in result.message + + def test_a_free_day_is_refused_with_the_reason(self) -> None: + freedays.mark(date.today(), reason="test") + result = _sick.mark_today_sick() + assert result.ok is False + assert result.message + assert sickdays.is_sick_day() is False + + def test_push_runs_sync_quietly_off_the_ui_thread(self) -> None: + ran = threading.Event() + with patch.object(sickdays, "sync_quietly", side_effect=ran.set): + worker = _sick.push_in_background() + worker.join(timeout=5) + assert ran.is_set() + assert worker.daemon is False + + +class TestButtonEverywhere: + def test_retry_screen_always_offers_it( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker._show_retry_and_sick("nope") + assert "I'm sick" in _button_texts(mock_tk) + + def test_phone_penalty_screen_offers_it_without_waiting( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker._show_phone_penalty("no phone") + assert "I'm sick" in _button_texts(mock_tk) + + def test_relaxed_prompt_and_retry_offer_it( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker_relaxed_day(mock_tk, tmp_path) + locker._start_relaxed_day_flow() + assert "I'm sick" in _button_texts(mock_tk) + mock_tk.Button.reset_mock() + locker._show_relaxed_retry("nothing", "no_workout") + assert "I'm sick" in _button_texts(mock_tk) + + +class TestTap: + def test_success_marks_lifts_pushes_records_and_unlocks( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + floor, close = MagicMock(), MagicMock() + object.__setattr__(locker, "_apply_sick_floor", floor) + object.__setattr__(locker, "close", close) + with ( + patch.object(_ui_flows_sick, "push_in_background") as push, + patch("screen_locker._startup_checks.record_decision") as record, + ): + locker.mark_sick_and_unlock() + assert sickdays.is_sick_day() is True + floor.assert_called_once_with() + push.assert_called_once_with() + assert record.call_args.args[0].reason == "sick_day_marked" + close.assert_called_once_with() + + def test_refusal_stays_locked_and_says_why( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + close = MagicMock() + object.__setattr__(locker, "close", close) + refused = _sick.MarkResult(ok=False, message="today is a free day") + with ( + patch.object(_ui_flows_sick, "mark_today_sick", return_value=refused), + patch.object(_ui_flows_sick, "push_in_background") as push, + ): + locker.mark_sick_and_unlock() + close.assert_not_called() + push.assert_not_called() + assert "today is a free day" in _label_texts(mock_tk) + back = next( + c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == "Back" + ) + assert back.kwargs["command"] == locker._start_phone_check + + def test_refusal_on_the_relaxed_prompt_goes_back_to_it( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker_relaxed_day(mock_tk, tmp_path) + locker._show_sick_refused("nope") + back = next( + c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == "Back" + ) + assert back.kwargs["command"] == locker._start_relaxed_day_flow diff --git a/screen_locker/tests/test_sick_features.py b/screen_locker/tests/test_sick_features.py deleted file mode 100644 index 786156f..0000000 --- a/screen_locker/tests/test_sick_features.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Tests for sick-budget UI integration, finalize, debt-clear, and dialogs.""" -# pylint: disable=protected-access - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from screen_locker import _sick_tracker -from screen_locker._sick_tracker import SickHistory -from screen_locker.tests.conftest import create_locker - -if TYPE_CHECKING: - from pathlib import Path - - -# --------------------------------------------------------------------------- -# _ui_flows.py — branches added for sick budget + finalize -# --------------------------------------------------------------------------- - - -class TestShowRetryAndSickBudget: - """Tests for budget-aware _show_retry_and_sick.""" - - def test_shows_sick_button_when_budget_available( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Shows sick button when budget available.""" - locker = create_locker(mock_tk, tmp_path) - with patch.object(_sick_tracker, "load_history", return_value=SickHistory()): - locker._show_retry_and_sick("nope") - button_texts = { - call.args[1] for call in mock_tk.Button.call_args_list if len(call.args) > 1 - } - # Buttons are created via the helper which sets text via kwarg "text". - button_texts |= { - call.kwargs.get("text") for call in mock_tk.Button.call_args_list - } - assert "I'm sick" in button_texts - - def test_hides_sick_button_when_budget_exhausted( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Hides sick button when budget exhausted.""" - locker = create_locker(mock_tk, tmp_path) - full = SickHistory(sick_days=["2026-05-09"] * 99) - with ( - patch.object(_sick_tracker, "load_history", return_value=full), - patch.object(_sick_tracker, "is_budget_exhausted", return_value=True), - ): - locker._show_retry_and_sick("nope") - button_texts: set[str] = set() - for call in mock_tk.Button.call_args_list: - button_texts.add(call.kwargs.get("text", "")) - assert "I'm sick" not in button_texts - - -class TestProceedToSickCountdownLoadsHistory: - """Covers the no-cache branch of _proceed_to_sick_countdown.""" - - def test_loads_history_when_cache_missing( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Loads history when cache missing.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "clear_container", MagicMock()) - object.__setattr__( - locker, "_sick_mode_used_today", MagicMock(return_value=False) - ) - object.__setattr__( - locker, - "_adjust_shutdown_time_earlier", - MagicMock(return_value=True), - ) - with patch.object( - _sick_tracker, "load_history", return_value=SickHistory() - ) as mock_load: - locker._proceed_to_sick_countdown() - mock_load.assert_called_once() - assert hasattr(locker, "_sick_history_cache") - - -class TestFinalizeSickDay: - """Covers _finalize_sick_day branches including commitment penalty.""" - - def test_marks_commitment_broken_and_writes_debt( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Marks commitment broken and writes debt.""" - locker = create_locker(mock_tk, tmp_path) - locker.workout_data = {} - history = SickHistory(commitments={"2026-05-10": True}) - locker._sick_history_cache = history - object.__setattr__(locker, "unlock_screen", MagicMock()) - with ( - patch.object(_sick_tracker, "had_commitment_for_today", return_value=True), - patch.object(_sick_tracker, "save_history", return_value=True), - ): - locker._finalize_sick_day() - assert locker.workout_data["broke_commitment"] == "true" - assert locker.workout_data["type"] == "sick_day" - assert "debt" in locker.workout_data - locker.unlock_screen.assert_called_once() - - def test_loads_history_when_cache_missing( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Loads history when cache missing.""" - locker = create_locker(mock_tk, tmp_path) - locker.workout_data = {} - object.__setattr__(locker, "unlock_screen", MagicMock()) - with ( - patch.object( - _sick_tracker, "load_history", return_value=SickHistory() - ) as mock_load, - patch.object(_sick_tracker, "save_history", return_value=True), - ): - locker._finalize_sick_day() - mock_load.assert_called_once() - locker.unlock_screen.assert_called_once() - - -# --------------------------------------------------------------------------- -# screen_lock.py — _clear_debt_on_verified_workout branches -# --------------------------------------------------------------------------- - - -class TestClearDebtOnVerifiedWorkout: - """Tests for _clear_debt_on_verified_workout.""" - - def test_returns_none_when_not_phone_verified( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Returns none when not phone verified.""" - locker = create_locker(mock_tk, tmp_path) - locker.workout_data = {"type": "sick_day"} - assert locker._clear_debt_on_verified_workout() is None - - def test_returns_zero_when_no_debt( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Returns zero when no debt.""" - locker = create_locker(mock_tk, tmp_path) - locker.workout_data = {"type": "phone_verified"} - with patch.object( - _sick_tracker, "load_history", return_value=SickHistory(debt=0) - ): - assert locker._clear_debt_on_verified_workout() == 0 - - def test_decrements_when_debt_positive( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Decrements when debt positive.""" - locker = create_locker(mock_tk, tmp_path) - locker.workout_data = {"type": "phone_verified"} - history = SickHistory(debt=2) - with ( - patch.object(_sick_tracker, "load_history", return_value=history), - patch.object(_sick_tracker, "save_history", return_value=True) as mock_save, - ): - assert locker._clear_debt_on_verified_workout() == 1 - mock_save.assert_called_once() - - -class TestUnlockScreenCommitmentPrompt: - """Tests for unlock_screen branches around commitment prompt + debt label.""" - - def test_phone_verified_schedules_commitment_prompt( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Phone verified schedules commitment prompt.""" - locker = create_locker(mock_tk, tmp_path) - locker.workout_data = {"type": "phone_verified"} - locker.log_file = tmp_path / "log.json" - object.__setattr__(locker, "save_workout_log", MagicMock()) - object.__setattr__( - locker, - "_try_adjust_shutdown_for_workout", - MagicMock(return_value=False), - ) - object.__setattr__( - locker, - "_clear_debt_on_verified_workout", - MagicMock(return_value=0), - ) - locker.unlock_screen() - # The last after() call schedules the commitment prompt closure. - last_call = locker.root.after.call_args_list[-1] - assert last_call.args[0] == 1500 - - def test_non_verified_schedules_close_directly( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Non verified schedules close directly.""" - locker = create_locker(mock_tk, tmp_path) - locker.workout_data = {"type": "sick_day"} - locker.log_file = tmp_path / "log.json" - object.__setattr__(locker, "save_workout_log", MagicMock()) - object.__setattr__( - locker, - "_try_adjust_shutdown_for_workout", - MagicMock(return_value=False), - ) - object.__setattr__( - locker, - "_clear_debt_on_verified_workout", - MagicMock(return_value=None), - ) - locker.unlock_screen() - # close() goes through root.after directly. - locker.root.after.assert_called_with(1500, locker.close) - - def test_renders_debt_label_when_positive( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Renders debt label when positive.""" - locker = create_locker(mock_tk, tmp_path) - locker.workout_data = {"type": "phone_verified"} - locker.log_file = tmp_path / "log.json" - object.__setattr__(locker, "save_workout_log", MagicMock()) - object.__setattr__( - locker, - "_try_adjust_shutdown_for_workout", - MagicMock(return_value=True), - ) - object.__setattr__( - locker, - "_clear_debt_on_verified_workout", - MagicMock(return_value=2), - ) - locker.unlock_screen() - # _text was called via mock_tk.Label; just assert a Label call mentions debt. - labels = [call.kwargs.get("text", "") for call in mock_tk.Label.call_args_list] - assert any("Workout debt: 2" in t for t in labels) - - -# --------------------------------------------------------------------------- -# _sick_dialog.py — UI mixin -# --------------------------------------------------------------------------- diff --git a/screen_locker/tests/test_sick_features_part2.py b/screen_locker/tests/test_sick_features_part2.py deleted file mode 100644 index 55245c6..0000000 --- a/screen_locker/tests/test_sick_features_part2.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Tests for sick justification submission, commitment prompt, and paste disable.""" -# pylint: disable=protected-access - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from screen_locker import _sick_tracker -from screen_locker._sick_tracker import SickHistory -from screen_locker.tests.conftest import create_locker - -if TYPE_CHECKING: - from pathlib import Path - - -class TestSubmitSickJustification: - """Tests for _submit_sick_justification validation + persistence.""" - - def _setup_locker( - self, - mock_tk: MagicMock, - tmp_path: Path, - *, - fields: dict[str, object] | None = None, - ) -> object: - defaults: dict[str, object] = { - "symptom": "fever", - "onset": "last night", - "severity": 7, - "text": "x" * 200, - } - if fields: - defaults.update(fields) - locker = create_locker(mock_tk, tmp_path) - locker._sick_history_cache = SickHistory() - locker._sick_symptom_var = MagicMock() - locker._sick_symptom_var.get.return_value = defaults["symptom"] - locker._sick_onset_var = MagicMock() - locker._sick_onset_var.get.return_value = defaults["onset"] - locker._sick_severity_var = MagicMock() - locker._sick_severity_var.get.return_value = defaults["severity"] - locker._sick_text_widget = MagicMock() - locker._sick_text_widget.get.return_value = defaults["text"] - locker._sick_error_label = MagicMock() - object.__setattr__(locker, "_proceed_to_sick_countdown", MagicMock()) - return locker - - def test_validation_failure_displays_error( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path, fields={"symptom": ""}) - locker._submit_sick_justification() - locker._sick_error_label.config.assert_called_once() - locker._proceed_to_sick_countdown.assert_not_called() - - def test_severity_tcl_error_treated_as_invalid( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path) - locker._sick_severity_var.get.side_effect = ValueError("bad") - locker._submit_sick_justification() - locker._sick_error_label.config.assert_called_once() - - def test_save_failure_displays_error( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path) - with patch.object(_sick_tracker, "save_history", return_value=False): - locker._submit_sick_justification() - locker._sick_error_label.config.assert_called_once() - locker._proceed_to_sick_countdown.assert_not_called() - - def test_success_proceeds_to_countdown( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path) - with patch.object(_sick_tracker, "save_history", return_value=True): - locker._submit_sick_justification() - locker._proceed_to_sick_countdown.assert_called_once() - - -class TestCommitmentPrompt: - """Tests for _show_commitment_prompt + _tick_commitment_timeout + answer.""" - - def test_show_prompt_renders_buttons( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - on_done = MagicMock() - locker._show_commitment_prompt(on_done=on_done) - assert locker._commitment_done_fn is on_done - assert locker._commitment_remaining > 0 - - def test_tick_decrements( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - locker._commitment_remaining = 2 - locker._commitment_timer_label = MagicMock() - locker._tick_commitment_timeout() - assert locker._commitment_remaining == 1 - locker.root.after.assert_called() - - def test_tick_zero_auto_answers_no( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - on_done = MagicMock() - locker._commitment_done_fn = on_done - locker._commitment_remaining = 0 - locker._commitment_timer_label = MagicMock() - locker._tick_commitment_timeout() - on_done.assert_called_once() - - def test_answer_yes_persists_commitment( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - on_done = MagicMock() - locker._commitment_done_fn = on_done - history = SickHistory() - with ( - patch.object(_sick_tracker, "load_history", return_value=history), - patch.object(_sick_tracker, "save_history", return_value=True) as mock_save, - ): - locker._answer_commitment(commit=True) - mock_save.assert_called_once() - on_done.assert_called_once() - assert locker._commitment_done_fn is None - - def test_answer_no_skips_persistence( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - on_done = MagicMock() - locker._commitment_done_fn = on_done - with patch.object(_sick_tracker, "save_history") as mock_save: - locker._answer_commitment(commit=False) - mock_save.assert_not_called() - on_done.assert_called_once() - - def test_answer_with_no_done_fn_is_safe( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - # No _commitment_done_fn attribute set. - locker._answer_commitment(commit=False) - - -class TestDisablePaste: - """Tests for the _disable_paste helper.""" - - def test_swallows_tcl_error(self) -> None: - import tkinter as tk - - from screen_locker._sick_dialog import _disable_paste - - widget = MagicMock() - widget.bind.side_effect = tk.TclError("nope") - # Should not raise. - _disable_paste(widget) diff --git a/screen_locker/tests/test_sick_features_part3.py b/screen_locker/tests/test_sick_features_part3.py deleted file mode 100644 index e3dcb29..0000000 --- a/screen_locker/tests/test_sick_features_part3.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Tests for sick-budget UI integration, finalize, debt-clear, and dialogs.""" -# pylint: disable=protected-access - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from screen_locker import _sick_tracker -from screen_locker._sick_tracker import SickHistory -from screen_locker.tests.conftest import create_locker - -if TYPE_CHECKING: - from pathlib import Path - - -class TestShowSickJustification: - """Tests for the structured sick justification dialog.""" - - def test_renders_form_without_commitment( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Renders form without commitment.""" - locker = create_locker(mock_tk, tmp_path) - with patch.object(_sick_tracker, "load_history", return_value=SickHistory()): - locker._show_sick_justification() - assert locker._sick_history_cache.sick_days == [] - assert hasattr(locker, "_sick_submit_button") - # Submit button starts enabled (no commitment). - # config(state="disabled") only called for commitment path. - for call in locker._sick_submit_button.first.configure.call_args_list: - assert call.kwargs.get("state") != "disabled" - - def test_renders_form_with_commitment_disables_submit( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Renders form with commitment disables submit.""" - locker = create_locker(mock_tk, tmp_path) - history = SickHistory(commitments={"2026-05-10": True}) - with ( - patch.object(_sick_tracker, "load_history", return_value=history), - patch.object(_sick_tracker, "had_commitment_for_today", return_value=True), - ): - locker._show_sick_justification() - # Submit button was disabled and forced-delay started. - states = [ - call.kwargs.get("state") - for call in locker._sick_submit_button.first.configure.call_args_list - ] - assert "disabled" in states - - def test_renders_recent_history_when_present( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Renders recent history when present.""" - locker = create_locker(mock_tk, tmp_path) - history = SickHistory( - justifications=[ - {"date": "2026-05-01", "symptom": "fever", "severity": 7}, - ], - ) - with patch.object(_sick_tracker, "load_history", return_value=history): - locker._show_sick_justification() - labels = [call.kwargs.get("text", "") for call in mock_tk.Label.call_args_list] - assert any("Recent sick days" in t for t in labels) - - -class TestUpdateCommitmentForcedDelay: - """Tests for _update_commitment_forced_delay.""" - - def test_ticks_down( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Ticks down.""" - locker = create_locker(mock_tk, tmp_path) - locker._sick_submit_button = MagicMock() - locker._commitment_forced_remaining = 3 - locker._update_commitment_forced_delay() - assert locker._commitment_forced_remaining == 2 - locker.root.after.assert_called() - - def test_enables_when_done( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Enables when done.""" - locker = create_locker(mock_tk, tmp_path) - locker._sick_submit_button = MagicMock() - locker._commitment_forced_remaining = 0 - locker._update_commitment_forced_delay() - locker._sick_submit_button.config.assert_called_with( - text="SUBMIT", state="normal" - ) diff --git a/screen_locker/tests/test_sick_floor.py b/screen_locker/tests/test_sick_floor.py new file mode 100644 index 0000000..098c085 --- /dev/null +++ b/screen_locker/tests/test_sick_floor.py @@ -0,0 +1,119 @@ +"""The live sick floor: a day marked sick after its reset is lifted to 20:00. + +The lift is recorded like the grace lift, so a workout credited afterwards is +paid out of it first and lands on max(20:00, ladder) -- not 20:00 plus the +workout's whole reward. +""" + +from __future__ import annotations + +from datetime import date +import logging +from typing import TYPE_CHECKING, Any +from unittest.mock import patch + +import earned_time +import pytest + +from screen_locker import _grace_floor, _shutdown, _sick_floor +from screen_locker._earned import first_minutes +from screen_locker._grace_floor import plan_absorb +from screen_locker._shutdown import ShutdownMixin +from screen_locker.tests._sick_helpers import mark_sick + +if TYPE_CHECKING: + from collections.abc import Iterator + +# A Saturday on the ladder; any day works, the floor is 20:00 everywhere. +_DAY = date(2026, 10, 17) +_ISO = _DAY.isoformat() +_FLOOR = earned_time.SICK_SHUTDOWN_FLOOR_MINUTES + + +class Host(ShutdownMixin): + """The real floor and add paths over an in-memory config.""" + + def __init__(self, config: tuple[int, int, int] | None, *, ok: bool = True): + self.config = config + self.ok = ok + + def _read_shutdown_config(self) -> tuple[int, int, int] | None: + return self.config + + def _write_shutdown_config( + self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = False + ) -> bool: + assert restore + if self.ok: + self.config = (mon_wed, thu_sun, morning_end) + return self.ok + + +@pytest.fixture(autouse=True) +def _on_the_day() -> Iterator[Any]: + with ( + patch.object(_sick_floor, "today_str", return_value=_ISO), + patch.object(_grace_floor, "today_str", return_value=_ISO), + patch.object(_shutdown, "today_str", return_value=_ISO), + ): + yield + + +class TestApplySickFloor: + def test_not_sick_does_nothing(self) -> None: + host = Host((18 * 60, 18 * 60, 300)) + assert host._apply_sick_floor() is False + assert host.config == (18 * 60, 18 * 60, 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)) + assert host._apply_sick_floor() is True + # Saturday reads the Thu-Sun band (18:30): lifted by 90 minutes. + assert host.config == (_FLOOR, _FLOOR, 300) + assert plan_absorb(0) == (0, 90) + + def test_already_at_or_above_the_floor_is_left_alone(self) -> None: + mark_sick(_DAY) + host = Host((21 * 60, 21 * 60, 300)) + assert host._apply_sick_floor() is False + assert host.config == (21 * 60, 21 * 60, 300) + + def test_unreadable_config_is_logged( + self, caplog: pytest.LogCaptureFixture + ) -> None: + mark_sick(_DAY) + with caplog.at_level(logging.WARNING): + assert Host(None)._apply_sick_floor() is False + assert "config unreadable" in caplog.text + + def test_failed_write_records_no_lift( + self, caplog: pytest.LogCaptureFixture + ) -> None: + mark_sick(_DAY) + with caplog.at_level(logging.WARNING): + assert Host((18 * 60, 18 * 60, 300), ok=False)._apply_sick_floor() is False + assert "failed to write" in caplog.text + assert plan_absorb(0) == (0, 0) + + +class TestNoStackingOnTop: + def test_a_workout_after_the_mark_lands_on_the_ladder_not_above_it(self) -> None: + """Mark sick at 10:00 (18:50 -> 20:00), then a workout: the ladder value.""" + start = 18 * 60 + 50 + host = Host((start, start, 300)) + mark_sick(_DAY) + host._apply_sick_floor() + host._adjust_shutdown_time_later() + ladder = start + first_minutes(earned_time.WORKOUT, _DAY) + assert host.config is not None + assert host.config[1] == max(_FLOOR, ladder) + + def test_a_small_credit_is_absorbed_entirely(self) -> None: + host = Host((18 * 60, 18 * 60, 300)) + mark_sick(_DAY) + host._apply_sick_floor() + assert host._adjust_shutdown_time_by(60) is True + assert host.config is not None + assert host.config[1] == _FLOOR + assert plan_absorb(0) == (0, 60) diff --git a/screen_locker/tests/test_sick_tracker.py b/screen_locker/tests/test_sick_tracker.py deleted file mode 100644 index 706426b..0000000 --- a/screen_locker/tests/test_sick_tracker.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Tests for the sick-day tracker pure-logic module.""" -# pylint: disable=protected-access - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import patch - -from screen_locker import _sick_tracker -from screen_locker._constants import ( - SICK_BUDGET_PER_7_DAYS, - SICK_BUDGET_PER_30_DAYS, - SICK_BUDGET_PER_90_DAYS, - SICK_COMMITMENT_PENALTY_DAYS, - SICK_LOCKOUT_MULTIPLIER_PER_RECENT, - SICK_LOCKOUT_SECONDS, -) -from screen_locker._sick_tracker import ( - SickHistory, - add_sick_day, - budget_summary, - clear_one_debt, - compute_lockout_seconds, - count_in_window, - is_budget_exhausted, - load_history, - save_history, -) - -if TYPE_CHECKING: - from pathlib import Path - - -_TODAY = "2026-05-10" - - -class TestLoadHistory: - """Tests for load_history.""" - - def test_returns_empty_when_file_missing(self) -> None: - """Returns empty when file missing.""" - history = load_history() - assert history == SickHistory() - - def test_reads_existing_file(self, tmp_path: Path) -> None: - """Reads existing file.""" - target = tmp_path / "sick_history.json" - target.write_text( - '{"sick_days": ["2026-05-01"], "debt": 2,' - ' "commitments": {"2026-05-10": true},' - ' "broken_commitments": ["2026-05-09"],' - ' "justifications": [{"date": "2026-05-01"}]}' - ) - with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target): - history = load_history() - assert history.sick_days == ["2026-05-01"] - assert history.debt == 2 - assert history.commitments == {"2026-05-10": True} - assert history.broken_commitments == ["2026-05-09"] - assert history.justifications == [{"date": "2026-05-01"}] - - def test_returns_empty_on_corrupt_json(self, tmp_path: Path) -> None: - """Returns empty on corrupt JSON.""" - target = tmp_path / "sick_history.json" - target.write_text("not json") - with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target): - assert load_history() == SickHistory() - - def test_returns_empty_on_oserror(self, tmp_path: Path) -> None: - """Returns empty on oserror.""" - target = tmp_path / "sick_history.json" - target.write_text("{}") - with ( - patch.object(_sick_tracker, "SICK_HISTORY_FILE", target), - patch.object(type(target), "open", side_effect=OSError("boom")), - ): - assert load_history() == SickHistory() - - -class TestSaveHistory: - """Tests for save_history.""" - - def test_persists_history(self, tmp_path: Path) -> None: - """Persists history.""" - target = tmp_path / "sick_history.json" - with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target): - history = SickHistory(sick_days=["2026-05-01"], debt=1) - assert save_history(history) is True - reloaded = load_history() - assert reloaded == history - - def test_returns_false_on_oserror(self, tmp_path: Path) -> None: - """Returns false on oserror.""" - target = tmp_path / "missing_dir" / "sick_history.json" - with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target): - assert save_history(SickHistory()) is False - - -class TestCountInWindow: - """Tests for count_in_window.""" - - def test_counts_only_within_window(self) -> None: - """Counts only within window.""" - history = SickHistory( - sick_days=[ - "2026-05-09", # 1 day ago: in 7d, 30d, 90d - "2026-05-03", # 7 days ago: NOT in 7d (cutoff exclusive) - "2026-04-25", # 15 days ago: NOT in 7d, in 30d, 90d - "2026-01-01", # ~130 days ago: outside 90d - ], - ) - assert count_in_window(history, 7, today=_TODAY) == 1 - assert count_in_window(history, 30, today=_TODAY) == 3 - assert count_in_window(history, 90, today=_TODAY) == 3 - - def test_skips_invalid_date_strings(self) -> None: - """Skips invalid date strings.""" - history = SickHistory(sick_days=["bad-date", "2026-05-09"]) - assert count_in_window(history, 7, today=_TODAY) == 1 - - def test_returns_zero_when_today_invalid(self) -> None: - """Returns zero when today invalid.""" - history = SickHistory(sick_days=["2026-05-09"]) - assert count_in_window(history, 7, today="bogus") == 0 - - def test_uses_today_default_when_none(self) -> None: - """Uses today default when none.""" - history = SickHistory(sick_days=[]) - assert count_in_window(history, 7) == 0 - - -class TestIsBudgetExhausted: - """Tests for is_budget_exhausted.""" - - def test_false_when_under_budget(self) -> None: - """False when under budget.""" - assert is_budget_exhausted(SickHistory(), today=_TODAY) is False - - def test_true_when_weekly_exhausted(self) -> None: - """True when weekly exhausted.""" - history = SickHistory( - sick_days=["2026-05-09"] * SICK_BUDGET_PER_7_DAYS, - ) - assert is_budget_exhausted(history, today=_TODAY) is True - - def test_true_when_monthly_exhausted(self) -> None: - # Spread far enough apart to all be in 30d but not 7d. - """True when monthly exhausted.""" - history = SickHistory( - sick_days=[ - "2026-05-08", - "2026-04-28", - "2026-04-18", - ][:SICK_BUDGET_PER_30_DAYS], - ) - assert is_budget_exhausted(history, today=_TODAY) is True - - def test_true_when_quarterly_exhausted(self) -> None: - # All in 90d but only 1 in 30d. - """True when quarterly exhausted.""" - days = [ - "2026-05-09", - "2026-04-01", - "2026-03-15", - "2026-03-10", - "2026-03-05", - "2026-03-01", - "2026-02-28", - "2026-02-25", - "2026-02-20", - "2026-02-15", - ] - history = SickHistory(sick_days=days[:SICK_BUDGET_PER_90_DAYS]) - assert is_budget_exhausted(history, today=_TODAY) is True - - -class TestComputeLockoutSeconds: - """Tests for compute_lockout_seconds.""" - - def test_base_when_no_recent(self) -> None: - """Base when no recent.""" - assert ( - compute_lockout_seconds(SickHistory(), today=_TODAY) == SICK_LOCKOUT_SECONDS - ) - - def test_doubles_per_recent(self) -> None: - """Doubles per recent.""" - history = SickHistory(sick_days=["2026-05-09", "2026-04-20"]) - recent = 2 # both within 30d - expected = SICK_LOCKOUT_SECONDS * (SICK_LOCKOUT_MULTIPLIER_PER_RECENT**recent) - assert compute_lockout_seconds(history, today=_TODAY) == expected - - -class TestBudgetSummary: - """Tests for budget_summary.""" - - def test_renders_all_windows_and_debt(self) -> None: - """Renders all windows and debt.""" - history = SickHistory(sick_days=["2026-05-09"], debt=3) - summary = budget_summary(history, today=_TODAY) - assert "Sick:" in summary - assert "1/" in summary - assert "Debt: 3" in summary - - -class TestAddSickDay: - """Tests for add_sick_day.""" - - def test_adds_today_and_increments_debt(self) -> None: - """Adds today and increments debt.""" - history = SickHistory() - new_debt = add_sick_day(history, today=_TODAY) - assert history.sick_days == [_TODAY] - assert new_debt == 1 - - def test_idempotent_on_same_day(self) -> None: - """Idempotent on same day.""" - history = SickHistory(sick_days=[_TODAY], debt=0) - new_debt = add_sick_day(history, today=_TODAY) - assert history.sick_days == [_TODAY] - # Debt still increments by 1 even if the date is already present. - assert new_debt == 1 - - def test_double_penalty_when_commitment_broken(self) -> None: - """Double penalty when commitment broken.""" - history = SickHistory(broken_commitments=[_TODAY]) - new_debt = add_sick_day(history, today=_TODAY) - assert new_debt == SICK_COMMITMENT_PENALTY_DAYS - - -class TestClearOneDebt: - """Tests for clear_one_debt.""" - - def test_decrements_when_positive(self) -> None: - """Decrements when positive.""" - history = SickHistory(debt=2) - assert clear_one_debt(history) == 1 - assert history.debt == 1 - - def test_clamped_at_zero(self) -> None: - """Clamped at zero.""" - history = SickHistory(debt=0) - assert clear_one_debt(history) == 0 diff --git a/screen_locker/tests/test_sick_tracker_part2.py b/screen_locker/tests/test_sick_tracker_part2.py deleted file mode 100644 index 286f73c..0000000 --- a/screen_locker/tests/test_sick_tracker_part2.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Tests for the sick-day tracker pure-logic module.""" -# pylint: disable=protected-access - -from __future__ import annotations - -from unittest.mock import patch - -import pytest - -from screen_locker import _sick_tracker -from screen_locker._constants import ( - SICK_HISTORY_REVIEW_COUNT, - SICK_JUSTIFICATION_MIN_CHARS, - SICK_JUSTIFICATION_PREVIEW_CHARS, -) -from screen_locker._sick_tracker import ( - JustificationDraft, - SickHistory, - add_justification, - format_recent_justifications, - had_commitment_for_today, - mark_commitment_broken, - recent_justifications, - record_commitment_for_tomorrow, - validate_justification, -) - -_TODAY = "2026-05-10" - - -class TestRecordCommitment: - """Tests for record_commitment_for_tomorrow + had_commitment_for_today.""" - - def test_records_for_tomorrow(self) -> None: - """Records for tomorrow.""" - history = SickHistory() - result = record_commitment_for_tomorrow(history, today=_TODAY) - assert result == "2026-05-11" - assert history.commitments["2026-05-11"] is True - - def test_returns_today_when_today_invalid(self) -> None: - """Returns today when today invalid.""" - history = SickHistory() - result = record_commitment_for_tomorrow(history, today="bogus") - assert result == "bogus" - assert history.commitments == {} - - def test_had_commitment_returns_true(self) -> None: - """Had commitment returns true.""" - history = SickHistory(commitments={_TODAY: True}) - assert had_commitment_for_today(history, today=_TODAY) is True - - def test_had_commitment_returns_false(self) -> None: - """Had commitment returns false.""" - assert had_commitment_for_today(SickHistory(), today=_TODAY) is False - - -class TestMarkCommitmentBroken: - """Tests for mark_commitment_broken.""" - - def test_appends_when_committed(self) -> None: - """Appends when committed.""" - history = SickHistory(commitments={_TODAY: True}) - mark_commitment_broken(history, today=_TODAY) - assert history.broken_commitments == [_TODAY] - - def test_idempotent(self) -> None: - """Idempotent.""" - history = SickHistory(commitments={_TODAY: True}, broken_commitments=[_TODAY]) - mark_commitment_broken(history, today=_TODAY) - assert history.broken_commitments == [_TODAY] - - def test_noop_when_no_commitment(self) -> None: - """Noop when no commitment.""" - history = SickHistory() - mark_commitment_broken(history, today=_TODAY) - assert history.broken_commitments == [] - - -class TestValidateJustification: - """Tests for validate_justification.""" - - def _good_text(self) -> str: - return "x" * SICK_JUSTIFICATION_MIN_CHARS - - def _draft( - self, - *, - symptom: str | None = None, - onset: str | None = None, - severity: int | None = None, - text: str | None = None, - ) -> JustificationDraft: - return JustificationDraft( - symptom="fever" if symptom is None else symptom, - onset="last night" if onset is None else onset, - severity=7 if severity is None else severity, - text=self._good_text() if text is None else text, - ) - - def test_returns_none_when_valid(self) -> None: - """Returns none when valid.""" - assert validate_justification(self._draft()) is None - - def test_rejects_blank_symptom(self) -> None: - """Rejects blank symptom.""" - assert validate_justification(self._draft(symptom=" ")) is not None - - def test_rejects_blank_onset(self) -> None: - """Rejects blank onset.""" - assert validate_justification(self._draft(onset="")) is not None - - @pytest.mark.parametrize("severity", [0, 11, -1]) - def test_rejects_severity_out_of_range(self, severity: int) -> None: - """Rejects severity out of range.""" - assert validate_justification(self._draft(severity=severity)) is not None - - def test_rejects_short_text(self) -> None: - """Rejects short text.""" - assert validate_justification(self._draft(text="too short")) is not None - - -class TestAddJustification: - """Tests for add_justification.""" - - def _draft(self, text: str = " full description text ") -> JustificationDraft: - return JustificationDraft( - symptom="fever", - onset="last night", - severity=7, - text=text, - ) - - def test_appends_entry_with_hmac_when_key_present(self) -> None: - """Appends entry with HMAC when key present.""" - history = SickHistory() - with patch.object(_sick_tracker, "compute_entry_hmac", return_value="deadbeef"): - entry = add_justification(history, self._draft(), today=_TODAY) - assert history.justifications == [entry] - assert entry["hmac"] == "deadbeef" - assert entry["text"] == "full description text" - assert entry["symptom"] == "fever" - assert entry["severity"] == 7 - assert entry["date"] == _TODAY - - def test_omits_hmac_when_key_unavailable(self) -> None: - """Omits HMAC when key unavailable.""" - history = SickHistory() - with patch.object(_sick_tracker, "compute_entry_hmac", return_value=None): - entry = add_justification( - history, - self._draft(text="full description"), - today=_TODAY, - ) - assert "hmac" not in entry - - -class TestRecentJustifications: - """Tests for recent_justifications + format_recent_justifications.""" - - def test_returns_last_n(self) -> None: - """Returns last n.""" - history = SickHistory( - justifications=[{"i": i} for i in range(5)], - ) - assert recent_justifications(history, 2) == [{"i": 3}, {"i": 4}] - - def test_returns_empty_list_when_n_zero(self) -> None: - """Returns empty list when n zero.""" - history = SickHistory(justifications=[{"i": 0}]) - assert recent_justifications(history, 0) == [] - - def test_default_n_is_review_count(self) -> None: - """Default n is review count.""" - history = SickHistory( - justifications=[{"i": i} for i in range(SICK_HISTORY_REVIEW_COUNT + 5)], - ) - assert len(recent_justifications(history)) == SICK_HISTORY_REVIEW_COUNT - - def test_format_returns_empty_when_no_history(self) -> None: - """Format returns empty when no history.""" - assert format_recent_justifications(SickHistory()) == "" - - def test_format_renders_lines(self) -> None: - """Format renders lines.""" - history = SickHistory( - justifications=[ - {"date": "2026-05-01", "symptom": "fever", "severity": 7}, - {"date": "2026-04-15", "symptom": "headache", "severity": 4}, - ], - ) - out = format_recent_justifications(history) - assert "2026-05-01" in out - assert "fever" in out - assert "headache" in out - - def test_format_handles_missing_fields(self) -> None: - """Format handles missing fields.""" - history = SickHistory(justifications=[{}]) - out = format_recent_justifications(history) - assert "?" in out - - def test_format_keeps_a_long_symptom_to_one_line(self) -> None: - """A 48-char symptom wrapped and pushed the sick screen past 768 px.""" - history = SickHistory( - justifications=[ - {"date": "2026-05-01", "symptom": "x" * 48, "severity": 7}, - ], - ) - out = format_recent_justifications(history) - assert out.endswith("…") - assert len(out.split("— ")[1]) == SICK_JUSTIFICATION_PREVIEW_CHARS diff --git a/screen_locker/tests/test_status.py b/screen_locker/tests/test_status.py index e35df95..51d38c5 100644 --- a/screen_locker/tests/test_status.py +++ b/screen_locker/tests/test_status.py @@ -106,14 +106,12 @@ class TestRunStatusNormal: def test_sick_day_shown_when_no_log_entry( self, tmp_path: Path, capsys: pytest.CaptureFixture ) -> None: - """A date with no workout_log entry but in sick_history → shown as sick_day.""" - from datetime import datetime + """A date with no workout_log entry but in the sickdays pool → sick_day.""" + from screen_locker.tests._sick_helpers import mark_sick - today = datetime.now(tz=UTC).astimezone().date().isoformat() + mark_sick() eb_file = tmp_path / "eb.json" log_file = tmp_path / "log.json" - history_file = tmp_path / "sick_history.json" - history_file.write_text(json.dumps({"sick_days": [today]})) locker = _make_locker(log_file, n_filled=0) with ( patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file), diff --git a/screen_locker/tests/test_status_data_part2.py b/screen_locker/tests/test_status_data_part2.py index 50c286e..dcc4f0c 100644 --- a/screen_locker/tests/test_status_data_part2.py +++ b/screen_locker/tests/test_status_data_part2.py @@ -42,8 +42,6 @@ class TestGatherStatus: assert snap.shutdown.tonight is None assert snap.shutdown.rest_of_week[0].minutes == 20 * 60 assert snap.shutdown.rest_of_week[0].speculative is False - assert snap.sick_budget.used_7d == 0 - assert snap.sick_budget.exhausted is False assert snap.streak == 0 assert snap.early_bird_extended is False assert snap.lock_explanation.fired is True @@ -169,7 +167,7 @@ class TestDayStatus: def test_no_entries_not_sick(self) -> None: """No entries not sick.""" - day = _day_status(date(2024, 1, 5), [], set()) + day = _day_status(date(2024, 1, 5), [], frozenset()) assert day.entry_types == () assert day.counted is False assert day.day_count == 0 @@ -177,12 +175,12 @@ class TestDayStatus: def test_no_entries_but_sick(self) -> None: """No entries but sick.""" - day = _day_status(date(2024, 1, 5), [], {"2024-01-05"}) + day = _day_status(date(2024, 1, 5), [], frozenset({date(2024, 1, 5)})) assert day.is_sick_day is True def test_non_dict_entries_dropped(self) -> None: """Corrupt log data (non-dict entries in the list) are skipped.""" - day = _day_status(date(2024, 1, 5), ["corrupt-string-entry"], set()) + day = _day_status(date(2024, 1, 5), ["corrupt-string-entry"], frozenset()) assert day.entry_types == () assert day.counted is False assert day.day_count == 0 @@ -198,7 +196,7 @@ class TestDayStatus: {"workout_data": {"type": "manual_workout", "source": "tt"}}, {"workout_data": {"type": "manual_workout", "source": "squash"}}, ], - set(), + frozenset(), ) assert day.entry_types == ( "runnerup_verified", diff --git a/screen_locker/tests/test_status_data_part3.py b/screen_locker/tests/test_status_data_part3.py index 919778c..e9ae1a9 100644 --- a/screen_locker/tests/test_status_data_part3.py +++ b/screen_locker/tests/test_status_data_part3.py @@ -14,7 +14,8 @@ from unittest.mock import patch import freedays -from screen_locker._status_data import gather_status +from screen_locker._status_data import format_summary_line, gather_status +from screen_locker.tests._sick_helpers import mark_sick from screen_locker.tests.test_status_data import _files if TYPE_CHECKING: @@ -34,33 +35,17 @@ class TestGatherStatusLockSuppression: def test_sick_day_marks_today_and_skips_lock(self, tmp_path: Path) -> None: """Sick day marks today and skips lock.""" files = _files(tmp_path) - (tmp_path / "sick_history.json").write_text( - json.dumps({"sick_days": ["2024-01-05"], "debt": 1}) - ) + mark_sick("2024-01-05") 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 True - assert snap.sick_budget.used_7d == 1 - assert snap.sick_budget.debt == 1 + assert format_summary_line(snap).endswith("· sick day") assert snap.lock_explanation.fired is False assert snap.lock_explanation.stage == "sick_day" - def test_sick_budget_exhausted_flag(self, tmp_path: Path) -> None: - """Sick budget exhausted flag.""" - files = _files(tmp_path) - (tmp_path / "sick_history.json").write_text( - json.dumps({"sick_days": ["2024-01-05"], "debt": 0}) - ) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert snap.sick_budget.exhausted is True - 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_status_view.py b/screen_locker/tests/test_status_view.py index d9c19c4..7d46176 100644 --- a/screen_locker/tests/test_status_view.py +++ b/screen_locker/tests/test_status_view.py @@ -13,7 +13,6 @@ from screen_locker.tests._status_view_helpers import ( _lock_explanation, _make_window, _shutdown, - _sick_budget, _snapshot, _texts, _week, @@ -156,32 +155,6 @@ class TestSectionLockExplanation: assert any("Warsaw Temperature" in t for t in _texts(mock_tk)) -class TestSectionSickBudget: - """SectionSickBudget.""" - - def test_exhausted_uses_warning_color(self, mock_tk: MagicMock) -> None: - """Exhausted uses warning color.""" - snap = _snapshot(sick_budget=_sick_budget(used_7d=1, exhausted=True)) - window = _make_window(mock_tk, snap) - calls = [ - c - for c in mock_tk.Label.call_args_list - if "week" in c.kwargs.get("text", "") - ] - assert any(c.kwargs.get("fg") == window._colors.palette.danger for c in calls) - - def test_not_exhausted_uses_normal_color(self, mock_tk: MagicMock) -> None: - """Not exhausted uses normal color.""" - snap = _snapshot(sick_budget=_sick_budget(used_7d=0, exhausted=False)) - window = _make_window(mock_tk, snap) - calls = [ - c - for c in mock_tk.Label.call_args_list - if "week" in c.kwargs.get("text", "") - ] - assert any(c.kwargs.get("fg") == window._colors.palette.muted for c in calls) - - class TestSectionShutdown: """SectionShutdown.""" diff --git a/screen_locker/tests/test_status_view_part2.py b/screen_locker/tests/test_status_view_part2.py index 9be240a..8e2a8c7 100644 --- a/screen_locker/tests/test_status_view_part2.py +++ b/screen_locker/tests/test_status_view_part2.py @@ -149,7 +149,6 @@ class TestRefreshAndCheckPhoneFlow: fake_verifier._apply_workout_credit = MagicMock( return_value=WorkoutCreditResult( shutdown_adjusted=True, - new_debt=None, extra_bonus_delta=0, weekly_count=1, already_counted_today=False, @@ -186,7 +185,6 @@ class TestRefreshAndCheckPhoneFlow: fake_verifier._apply_workout_credit = MagicMock( return_value=WorkoutCreditResult( shutdown_adjusted=False, - new_debt=None, extra_bonus_delta=0, weekly_count=1, already_counted_today=False, diff --git a/screen_locker/tests/test_surface_fanout.py b/screen_locker/tests/test_surface_fanout.py index ae0b5eb..ded27bc 100644 --- a/screen_locker/tests/test_surface_fanout.py +++ b/screen_locker/tests/test_surface_fanout.py @@ -14,7 +14,7 @@ from unittest.mock import MagicMock import pytest -from screen_locker._surface_group import FrameGroup, TextGroup, WidgetGroup +from screen_locker._surface_group import FrameGroup, WidgetGroup from screen_locker.tests.conftest import TWO_OUTPUTS, create_locker if TYPE_CHECKING: @@ -133,32 +133,6 @@ class TestGroupsInIsolation: child.destroy.assert_called_once_with() - def test_text_reads_whichever_copy_was_typed_into(self) -> None: - """Text boxes cannot share a variable, so the non-empty one wins.""" - untouched, typed = MagicMock(), MagicMock() - untouched.get.return_value = " " - typed.get.return_value = "I have a migraine" - - group = TextGroup([untouched, typed]) - - assert group.get("1.0", "end") == "I have a migraine" - - def test_text_falls_back_to_the_primary_when_all_blank(self) -> None: - """An untouched form still yields a string for validation to reject.""" - first, second = MagicMock(), MagicMock() - first.get.return_value = "" - second.get.return_value = "" - - assert TextGroup([first, second]).get("1.0", "end") == "" - - def test_text_skips_a_dead_copy(self) -> None: - """A destroyed monitor's box is stepped over, not fatal.""" - dead, typed = MagicMock(), MagicMock() - dead.get.side_effect = tk.TclError("gone") - typed.get.return_value = "fever since 3am" - - assert TextGroup([dead, typed]).get("1.0", "end") == "fever since 3am" - def test_single_builds_a_group_of_one(self) -> None: """The non-lock windows' degenerate case.""" parent = MagicMock() diff --git a/screen_locker/tests/test_sync_degradation.py b/screen_locker/tests/test_sync_degradation.py index 116d8a9..725be5b 100644 --- a/screen_locker/tests/test_sync_degradation.py +++ b/screen_locker/tests/test_sync_degradation.py @@ -23,7 +23,6 @@ from crdt_sync import FirebaseAuthError from screen_locker import _sync_client, _workout_sync from screen_locker._compliance_state import explain_lock_decision from screen_locker._credential_recovery import RecoveryResult -from screen_locker._sick_tracker import SickHistory from screen_locker.tests._workout_sync_fixtures import ( ReachableClient, _firebase_config, @@ -61,7 +60,6 @@ class TestUnreadableSourceIsNotNoWorkout: result = explain_lock_decision( **self._files(tmp_path), now=self._NOON, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=False, degraded_sources=(_sync_client.DegradedSource("firebase", "HTTP 400"),), @@ -75,7 +73,6 @@ class TestUnreadableSourceIsNotNoWorkout: result = explain_lock_decision( **self._files(tmp_path), now=self._NOON, - sick_history=SickHistory(), weekly_minimum_met=False, relaxed_day=False, ) diff --git a/screen_locker/tests/test_ui_and_timers.py b/screen_locker/tests/test_ui_and_timers.py index bc69546..ac04fcf 100644 --- a/screen_locker/tests/test_ui_and_timers.py +++ b/screen_locker/tests/test_ui_and_timers.py @@ -1,4 +1,4 @@ -"""Tests for UI transitions, timer logic, and sick day screens.""" +"""Tests for UI transitions, timer logic, and the retry screen.""" from __future__ import annotations @@ -121,65 +121,6 @@ class TestTimerLogic: locker._start_phone_check.assert_called_once() -class TestAskIfSick: - """Tests for ask_if_sick method.""" - - def test_ask_if_sick_invokes_justification_dialog( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """ask_if_sick now delegates to the structured justification dialog.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "_show_sick_justification", MagicMock()) - locker.ask_if_sick() - locker._show_sick_justification.assert_called_once_with() - - -class TestGetSickDayStatus: - """Tests for _get_sick_day_status method.""" - - def test_already_adjusted_today( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Test status when sick mode already used today.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, "_sick_mode_used_today", MagicMock(return_value=True) - ) - text, color = locker._get_sick_day_status() - assert "already adjusted" in text - assert color == locker._colors.palette.warning - - def test_adjustment_success( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Test status when shutdown time adjusted successfully.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, "_sick_mode_used_today", MagicMock(return_value=False) - ) - object.__setattr__( - locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=True) - ) - text, color = locker._get_sick_day_status() - assert "earlier" in text - assert color == locker._colors.palette.success - - def test_adjustment_failure( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Test status when adjustment fails.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, "_sick_mode_used_today", MagicMock(return_value=False) - ) - object.__setattr__( - locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=False) - ) - text, color = locker._get_sick_day_status() - assert "Could not adjust" in text - assert color == locker._colors.palette.danger - - class TestShowRetryAndSick: """Tests for _show_retry_and_sick method.""" diff --git a/screen_locker/tests/test_ui_and_timers_part2.py b/screen_locker/tests/test_ui_and_timers_part2.py deleted file mode 100644 index 53ec95e..0000000 --- a/screen_locker/tests/test_ui_and_timers_part2.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Tests for sick-day countdown flow.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock - -from screen_locker._sick_tracker import SickHistory -from screen_locker.screen_lock import ( - SICK_LOCKOUT_SECONDS, -) -from screen_locker.tests.conftest import create_locker - -if TYPE_CHECKING: - from pathlib import Path - - -class TestProceedToSickCountdown: - """Tests for _proceed_to_sick_countdown.""" - - def test_sets_up_countdown( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Countdown initialises with computed escalated value.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "clear_container", MagicMock()) - object.__setattr__( - locker, "_sick_mode_used_today", MagicMock(return_value=False) - ) - object.__setattr__( - locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=True) - ) - locker._sick_history_cache = SickHistory() - locker._proceed_to_sick_countdown() - locker.clear_container.assert_called_once() - # First tick has decremented once -> base - 1 - assert locker.sick_remaining_time == SICK_LOCKOUT_SECONDS - 1 - - -class TestShowSickDayUi: - """Tests for _show_sick_day_ui method.""" - - def test_displays_ui( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """_show_sick_day_ui displays labels with explicit countdown.""" - locker = create_locker(mock_tk, tmp_path) - locker._show_sick_day_ui("Test status", "#00aa00", 120) - mock_tk.Label.assert_called() - assert hasattr(locker, "sick_countdown_label") diff --git a/screen_locker/tests/test_ui_flows_part2.py b/screen_locker/tests/test_ui_flows_part2.py index 986b768..a500bc2 100644 --- a/screen_locker/tests/test_ui_flows_part2.py +++ b/screen_locker/tests/test_ui_flows_part2.py @@ -11,30 +11,6 @@ if TYPE_CHECKING: from pathlib import Path -class TestUpdateSickCountdownAtZero: - """Tests for _update_sick_countdown at zero remaining.""" - - def test_records_sick_day_and_unlocks_at_zero( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test countdown at zero records sick day and calls unlock.""" - locker = create_locker(mock_tk, tmp_path) - locker.sick_remaining_time = 0 - locker.sick_countdown_label = MagicMock() - locker.workout_data = {} - locker.log_file = tmp_path / "log.json" - object.__setattr__(locker, "unlock_screen", MagicMock()) - - locker._update_sick_countdown() - - assert locker.workout_data["type"] == "sick_day" - assert locker.workout_data["note"] == "Sick day - shutdown moved earlier" - locker.unlock_screen.assert_called_once() - - class TestStartRunnerupFallback: """Tests for _start_runnerup_fallback (lines 114-121).""" diff --git a/screen_locker/tests/test_verify_workout.py b/screen_locker/tests/test_verify_workout.py deleted file mode 100644 index b06a1ae..0000000 --- a/screen_locker/tests/test_verify_workout.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Tests for post-sick-day workout verification (--verify-workout).""" - -from __future__ import annotations - -from datetime import UTC, datetime -import json -from typing import TYPE_CHECKING -from unittest.mock import MagicMock - -import pytest - -from screen_locker.tests.conftest import create_locker - -if TYPE_CHECKING: - from pathlib import Path - - -class TestIsSickDayToday: - """Tests for _is_sick_day_today method. - - sick_day is tracked in sick_history.json (via _sick_tracker.py) as the - sole source of truth -- not in log.json. The autouse - _isolate_sick_history fixture redirects SICK_HISTORY_FILE to - tmp_path/sick_history.json for every test. - """ - - def test_no_history_file( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Return False when sick_history.json does not exist.""" - locker = create_locker(mock_tk, tmp_path) - assert locker._is_sick_day_today() is False - - def test_today_not_sick_day( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Return False when today is not in sick_history's sick_days list.""" - locker = create_locker(mock_tk, tmp_path) - history_file = tmp_path / "sick_history.json" - history_file.write_text(json.dumps({"sick_days": ["2020-01-01"]})) - assert locker._is_sick_day_today() is False - - def test_today_is_sick_day( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Return True when today is in sick_history's sick_days list.""" - locker = create_locker(mock_tk, tmp_path) - today = datetime.now(tz=UTC).strftime("%Y-%m-%d") - history_file = tmp_path / "sick_history.json" - history_file.write_text(json.dumps({"sick_days": [today]})) - assert locker._is_sick_day_today() is True - - -class TestVerifyOnlyInit: - """Tests for ScreenLocker initialization with verify_only=True.""" - - def test_verify_only_exits_when_no_sick_day( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Exit when verify_only but no sick day logged today.""" - mock_sys_exit.side_effect = SystemExit(0) - with pytest.raises(SystemExit): - create_locker( - mock_tk, - tmp_path, - verify_only=True, - is_sick_day_log=False, - ) - mock_sys_exit.assert_called_once_with(0) - - def test_verify_only_starts_when_sick_day( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Start verification window when sick day is logged.""" - locker = create_locker( - mock_tk, - tmp_path, - verify_only=True, - is_sick_day_log=True, - ) - assert locker.verify_only is True - mock_sys_exit.assert_not_called() - - def test_verify_only_sets_title( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Verify window title includes [VERIFY].""" - locker = create_locker( - mock_tk, - tmp_path, - verify_only=True, - is_sick_day_log=True, - ) - locker.root.title.assert_called_with("Workout Locker [VERIFY]") - - def test_close_and_run_use_root_directly( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """No LockWindow is built for verify_only; close()/run() use root.""" - locker = create_locker( - mock_tk, - tmp_path, - verify_only=True, - is_sick_day_log=True, - ) - assert locker._lock is None - - locker.run() - locker.root.mainloop.assert_called_once() - - locker.close() - locker.root.destroy.assert_called_once() - - -class TestSetupVerifyWindow: - """Tests for _setup_verify_window.""" - - def test_sizes_itself_to_content_with_a_floor( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Verify window fits its content, with 600x400 as a floor not a cap. - - This asserted ``geometry("600x400")``, which pinned the window to a size - smaller than what it displays -- the same defect that left the - relaxed-day prompt's 862px button row sheared inside a 700px window. - ``geometry("")`` hands sizing to the geometry manager, ``minsize`` keeps - the old dimensions as a floor, and ``maxsize`` stops a wide screen's - content pushing the window off-display. - """ - locker = create_locker( - mock_tk, - tmp_path, - verify_only=True, - is_sick_day_log=True, - ) - locker.root.geometry.assert_called_with("") - locker.root.minsize.assert_called_once() - locker.root.maxsize.assert_called_once() - locker.root.configure.assert_called_with( - bg=locker._colors.palette.bg, - cursor="arrow", - ) - locker.root.protocol.assert_called_with( - "WM_DELETE_WINDOW", - locker.close, - ) - - -class TestStartVerifyWorkoutCheck: - """Tests for _start_verify_workout_check.""" - - def test_starts_phone_check_and_polls( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Start phone verification and begin polling.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_verify_phone_workout", - MagicMock(return_value=("verified", "ok")), - ) - object.__setattr__( - locker, - "_poll_verify_workout_check", - MagicMock(), - ) - - locker._start_verify_workout_check() - - assert locker._phone_future is not None - locker._poll_verify_workout_check.assert_called_once() - - -class TestPollVerifyWorkoutCheck: - """Tests for _poll_verify_workout_check.""" - - def test_schedules_retry_when_not_done( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Re-schedule polling when future is not done.""" - locker = create_locker(mock_tk, tmp_path) - mock_future = MagicMock() - mock_future.done.return_value = False - locker._phone_future = mock_future - - locker._poll_verify_workout_check() - - locker.root.after.assert_called_with( - 500, - locker._poll_verify_workout_check, - ) - - def test_handles_result_when_done( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Route to result handler when future is done.""" - locker = create_locker(mock_tk, tmp_path) - mock_future = MagicMock() - mock_future.done.return_value = True - mock_future.result.return_value = ("verified", "Found workout") - locker._phone_future = mock_future - object.__setattr__( - locker, - "_handle_verify_workout_result", - MagicMock(), - ) - - locker._poll_verify_workout_check() - - locker._handle_verify_workout_result.assert_called_once_with( - "verified", - "Found workout", - ) diff --git a/screen_locker/tests/test_verify_workout_part2.py b/screen_locker/tests/test_verify_workout_part2.py deleted file mode 100644 index 716c58c..0000000 --- a/screen_locker/tests/test_verify_workout_part2.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Tests for post-sick-day workout verification (--verify-workout).""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock - -from screen_locker.tests.conftest import create_locker - -if TYPE_CHECKING: - from pathlib import Path - - -class TestHandleVerifyWorkoutResult: - """Tests for _handle_verify_workout_result.""" - - def test_verified_adjusts_shutdown_and_saves( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """On verified: adjust shutdown, save log, show success.""" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = tmp_path / "log.json" - object.__setattr__( - locker, - "_adjust_shutdown_time_later", - MagicMock(return_value=True), - ) - - locker._handle_verify_workout_result("verified", "1 session found") - - assert locker.workout_data["type"] == "phone_verified" - assert locker.workout_data["after_sick_day"] == "true" - locker._adjust_shutdown_time_later.assert_called_once() - locker.root.after.assert_called() - - def test_verified_without_adjustment( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """On verified but adjustment fails: still saves and shows success.""" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = tmp_path / "log.json" - object.__setattr__( - locker, - "_adjust_shutdown_time_later", - MagicMock(return_value=False), - ) - - locker._handle_verify_workout_result("verified", "1 session found") - - assert locker.workout_data["type"] == "phone_verified" - locker.root.after.assert_called() - - def test_not_verified_shows_retry( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """On not_verified: show retry screen.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_show_verify_retry", - MagicMock(), - ) - - locker._handle_verify_workout_result( - "not_verified", - "No workout today", - ) - - locker._show_verify_retry.assert_called_once_with( - "No workout today", - ) - - def test_error_shows_retry( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """On error: show retry screen.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_show_verify_retry", - MagicMock(), - ) - - locker._handle_verify_workout_result("error", "ADB failed") - - locker._show_verify_retry.assert_called_once_with("ADB failed") - - -class TestShowVerifyRetry: - """Tests for _show_verify_retry.""" - - def test_shows_retry_and_close_buttons( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Show TRY AGAIN and Close buttons.""" - locker = create_locker(mock_tk, tmp_path) - - locker._show_verify_retry("No workout found") - - # Verify container was cleared and buttons were packed - locker.container.first.winfo_children.return_value = [] diff --git a/screen_locker/tests/test_wake_skip.py b/screen_locker/tests/test_wake_skip.py index 56463ba..91a2b66 100644 --- a/screen_locker/tests/test_wake_skip.py +++ b/screen_locker/tests/test_wake_skip.py @@ -8,7 +8,6 @@ from unittest.mock import MagicMock, patch from screen_locker._morning_session import MorningSkip from screen_locker.screen_lock import ScreenLocker -from screen_locker.tests.conftest import create_locker _SKIP = MorningSkip(outcome="completed", exempt_until=datetime(2099, 1, 1).astimezone()) @@ -26,7 +25,6 @@ def _locker( patch.object(ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False), patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_relaxed_day_flow"), - patch.object(ScreenLocker, "_start_verify_workout_check"), patch.object(ScreenLocker, "_scan_and_fill_week_runnerup", return_value=0), ): ScreenLocker(demo_mode=True) @@ -61,10 +59,3 @@ class TestWakeSkipIntegration: _locker(tmp_path, carrot=_SKIP, has_logged=True) mock_sys_exit.assert_called_once_with(0) assert record.call_args.args[0].reason == "workout_logged_today" - - def test_verify_only_mode_ignores_wake_skip( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - with patch("screen_locker._early_bird.morning_skip_today", return_value=_SKIP): - create_locker(mock_tk, tmp_path, verify_only=True, is_sick_day_log=True) - mock_sys_exit.assert_not_called() diff --git a/screen_locker/tests/test_weekly_logic.py b/screen_locker/tests/test_weekly_logic.py index 09f51e4..0449acf 100644 --- a/screen_locker/tests/test_weekly_logic.py +++ b/screen_locker/tests/test_weekly_logic.py @@ -56,7 +56,6 @@ class TestRelaxedDayBranch: ), patch.object(ScreenLocker, "_start_phone_check") as mock_phone, patch.object(ScreenLocker, "_start_relaxed_day_flow") as mock_relaxed, - patch.object(ScreenLocker, "_start_verify_workout_check"), ): ScreenLocker(demo_mode=True) @@ -91,7 +90,6 @@ class TestRelaxedDayBranch: patch.object(ScreenLocker, "_setup_relaxed_day_window") as mock_small, patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_relaxed_day_flow"), - patch.object(ScreenLocker, "_start_verify_workout_check"), ): ScreenLocker(demo_mode=True) @@ -125,7 +123,6 @@ class TestRelaxedDayBranch: patch("screen_locker.screen_lock.LockWindow") as mock_lock_window, patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_relaxed_day_flow"), - patch.object(ScreenLocker, "_start_verify_workout_check"), ): ScreenLocker(demo_mode=True) diff --git a/screen_locker/tests/test_weekly_logic_part2.py b/screen_locker/tests/test_weekly_logic_part2.py index 5320b19..8ab2098 100644 --- a/screen_locker/tests/test_weekly_logic_part2.py +++ b/screen_locker/tests/test_weekly_logic_part2.py @@ -102,22 +102,6 @@ class TestCheckTodayStateExits: result = locker._check_today_state_exits() assert result is True - def test_sick_day_auto_upgrade_returns_true( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Sick day auto upgrade returns true.""" - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_is_early_bird_pending", return_value=False), - patch.object(locker, "_is_sick_day_today", return_value=True), - patch.object(locker, "_try_auto_upgrade_sick_day", return_value=True), - ): - result = locker._check_today_state_exits() - assert result is True - def test_sick_day_no_upgrade_still_returns_true( self, mock_tk: MagicMock, @@ -133,7 +117,6 @@ class TestCheckTodayStateExits: with ( patch.object(locker, "_is_early_bird_pending", return_value=False), patch.object(locker, "_is_sick_day_today", return_value=True), - patch.object(locker, "_try_auto_upgrade_sick_day", return_value=False), ): result = locker._check_today_state_exits() assert result is True diff --git a/screen_locker/tests/test_weekly_logic_part5.py b/screen_locker/tests/test_weekly_logic_part5.py index ff2f408..3a32a18 100644 --- a/screen_locker/tests/test_weekly_logic_part5.py +++ b/screen_locker/tests/test_weekly_logic_part5.py @@ -51,7 +51,6 @@ class TestRelaxedDayAlreadySkippedBranch: ), patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_relaxed_day_flow") as mock_relaxed, - patch.object(ScreenLocker, "_start_verify_workout_check"), ): locker = ScreenLocker(demo_mode=True) diff --git a/screen_locker/tests/test_workday_penalty.py b/screen_locker/tests/test_workday_penalty.py index 2aed6d3..0d913b2 100644 --- a/screen_locker/tests/test_workday_penalty.py +++ b/screen_locker/tests/test_workday_penalty.py @@ -12,6 +12,7 @@ import pytest from screen_locker import _workday_penalty from screen_locker._workday_penalty import workday_penalty_today +from screen_locker.tests._sick_helpers import mark_sick if TYPE_CHECKING: from pathlib import Path @@ -60,3 +61,29 @@ class TestVerdict: def test_default_now_is_used_when_unset(self) -> None: # Not asserting a value -- just that omitting `now` does not raise. assert workday_penalty_today() is False + + +class TestSickDayVoidsThePenalty: + """Void when EITHER the missed ring's day OR the penalised day is sick.""" + + def test_a_sick_penalty_date_voids_it(self) -> None: + mark_sick("2026-09-23") + _write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"}) + assert workday_penalty_today(NOW) is False + + def test_a_sick_issued_date_voids_it(self) -> None: + """Missed at 08:15, marked sick at 14:00: wake-alarm's retraction lags.""" + mark_sick("2026-09-22") + _write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"}) + assert workday_penalty_today(NOW) is False + + def test_an_older_file_without_issued_date_still_applies(self) -> None: + _write({"penalty_date": "2026-09-23"}) + assert workday_penalty_today(NOW) is True + + def test_a_malformed_issued_date_is_logged_not_sick( + self, caplog: pytest.LogCaptureFixture + ) -> None: + _write({"penalty_date": "2026-09-23", "issued_date": "yesterday"}) + assert workday_penalty_today(NOW) is True + assert "not a date" in caplog.text diff --git a/scripts/_preview_sim.py b/scripts/_preview_sim.py index be5674f..b3d1a1b 100644 --- a/scripts/_preview_sim.py +++ b/scripts/_preview_sim.py @@ -2,21 +2,22 @@ Split out to keep the script under the 250-line cap. Every class here runs the production methods it inherits; only the I/O edges (the drop directory, -adb, the sudo-written config, the sick history) point at a temporary place. +adb, the sudo-written config, the sickdays pool) point at a temporary place. """ from __future__ import annotations from datetime import date, datetime, time -import json +import importlib from typing import TYPE_CHECKING import earned_time +import sickdays -from screen_locker import _grace_floor, _sick_tracker from screen_locker._earned import first_minutes from screen_locker._runnerup_verification import RunnerUpVerificationMixin from screen_locker._shutdown import ShutdownMixin +from screen_locker._sick import is_sick if TYPE_CHECKING: from pathlib import Path @@ -88,18 +89,41 @@ class SimShutdown(ShutdownMixin): self.config = (*clamp, morning_end) return True - def credit(self, name: str, day: date, first_done: float) -> None: - """One live pass: the earner's add (lift-absorbing), then the floor.""" - if name == earned_time.WORKOUT.name: + def credit(self, name: str, day: date, first_done: float | None) -> None: + """One live pass: the earner's add (lift-absorbing), then the floors. + + ``sick`` is not an earner: it marks the (temporary) pool and runs the + live sick-floor pass, as the lock screen's button and the 5-minute + tick do. + """ + if name == SICK_STEP: + sick_pool(day, sick_day=True) + elif name == earned_time.WORKOUT.name: self._adjust_shutdown_time_later() else: self._adjust_shutdown_time_by(first_minutes(earned_time.earner(name), day)) - self._apply_grace_floor(None, first_done=first_done) + self._apply_sick_floor() + if first_done is not None: + self._apply_grace_floor(None, first_done=first_done) -def sick_history(day: date, tmp: Path, *, sick_day: bool) -> bool: - """A temp sick history naming ``day``, read back through the real check.""" - history = tmp / "sick_history.json" - history.write_text(json.dumps({"sick_days": [day.isoformat()] if sick_day else []})) - vars(_sick_tracker)["SICK_HISTORY_FILE"] = history - return _grace_floor.sick_on(day) +# The --sequence step that marks the day sick instead of crediting an earner. +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 + + +def sick_pool(day: date, *, sick_day: bool) -> bool: + """Mark ``day`` sick in the temp pool, read back through the real check. + + Call :func:`use_temp_pool` first: this writes through sickdays' own + ``add_sick_day``, with ``day`` as "today" and no free-day check. + """ + if sick_day and not is_sick(day): + sickdays.add_sick_day(day, is_free=lambda _day: False, now=day) + return is_sick(day) diff --git a/scripts/annotate_duplicate_credits.py b/scripts/annotate_duplicate_credits.py index 7183969..48976d4 100755 --- a/scripts/annotate_duplicate_credits.py +++ b/scripts/annotate_duplicate_credits.py @@ -12,8 +12,8 @@ Annotates, never deletes, for two independent reasons. Deleting is self-undoing: every re-ingest path dedups against ``log.json``'s own contents, so ``_session_sync`` re-pulls a removed ``pc_workout_verified`` from the CRDT store on the next 15-minute ``workout-sync.timer`` tick and re-applies its -shutdown credit. And it is the repo's stated position on signed data -- see -``scripts/revoke_sick_day.py``. No entry is modified, so no HMAC is touched. +shutdown credit. And it is the repo's stated position on signed data: never +rewrite a signed entry. No entry is modified, so no HMAC is touched. The ledger is descriptive: nothing reads it to decide a lock. If it ever disagrees with :func:`~screen_locker._weekly_check.count_day_credits` the diff --git a/scripts/finish_2026_08_24.sh b/scripts/finish_2026_08_24.sh deleted file mode 100755 index e6c725a..0000000 --- a/scripts/finish_2026_08_24.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/bin/bash - -# ============================================================================ -# Finish the 2026-08-24 "workout not found" recovery, end to end. -# -# Everything Claude could not run itself, in the one order that works. The -# ordering is not cosmetic: `sick_day` fires BEFORE `already_logged` in the -# lock chain, so while the sick day stands it short-circuits the decision and -# hides whether the workout credit actually landed. Credit first, revoke -# second, re-arm last. -# -# Safe to re-run: every step is idempotent, and the script stops at the first -# failure rather than pressing on to re-arm a locker that cannot see today's -# workout. -# -# Usage: -# ./scripts/finish_2026_08_24.sh # do everything, then re-arm -# ./scripts/finish_2026_08_24.sh --no-arm # ...but leave enforcement off -# ============================================================================ - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -readonly SCRIPT_DIR -readonly PY="$SCRIPT_DIR/.venv/bin/python" -readonly TARGET_DATE="2026-08-24" - -ARM=1 - -usage() { - echo "Usage: $(basename "$0") [--no-arm]" - echo " --no-arm Run every step but leave enforcement disarmed" - exit 0 -} - -step() { - echo "" - echo "==============================================================" - echo " $1" - echo "==============================================================" -} - -validate_requirements() { - if [[ ! -x $PY ]]; then - echo "Error: no venv interpreter at $PY — run ./run.sh once first" >&2 - exit 1 - fi -} - -main() { - validate_requirements - cd "$SCRIPT_DIR" - - step "1/6 Test suite (must be green before touching live state)" - "$PY" -m pytest screen_locker/tests/ -q --no-cov - - step "2/6 Lint + type gate" - "$PY" -m pre_commit run --all-files - - step "3/6 Sync: recover Firebase, then ingest today's workout" - # --sync-only never locks, so this is safe to run while disarmed. - ./run.sh --sync-only - - step "4/6 Did $TARGET_DATE actually land in the log?" - # Gate, don't warn: if the credit is not on disk there is nothing to - # revoke a sick day against, and re-arming here would lock you out again. - "$PY" scripts/verify_workout_credited.py --date "$TARGET_DATE" - - step "5/6 Revoke the sick days that this workout disproves" - "$PY" scripts/revoke_sick_day.py --date "$TARGET_DATE" --date 2026-06-12 - - if ((ARM)); then - step "6/6 Re-arm enforcement" - ./arm.sh --rearm - else - step "6/6 Skipped re-arm (--no-arm); enforcement stays OFF" - echo "Re-arm later with: ./arm.sh --rearm" - fi - - echo "" - echo "✓ Done." -} - -while [[ $# -gt 0 ]]; do - case $1 in - --no-arm) - ARM=0 - shift - ;; - -h | --help) - usage - ;; - *) - echo "Unknown option: $1" >&2 - exit 1 - ;; - esac -done - -main "$@" diff --git a/scripts/migrate_sick_history.py b/scripts/migrate_sick_history.py new file mode 100755 index 0000000..8339609 --- /dev/null +++ b/scripts/migrate_sick_history.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Move ``sick_history.json``'s sick days into the shared ``sickdays`` pool, once. + +screen-locker's old sick day lived in its own file, next to a budget, a +justification log, commitments and a debt counter -- all gone now. A sick day +is a date in the fleet-wide ``sickdays`` pool, nothing else, so only the +dates move (2026-06-21 and 2026-07-24 on kuhy's PC). Both are in the past, +which only ``sickdays.import_legacy`` may write. + +Idempotent, so ``install.sh`` runs it on every install: + +- no history file: nothing to do, exit 0; +- every date already sick in the pool: nothing is rewritten (no new HLC tick, + no audit line), the file is removed; +- otherwise the missing dates are imported, read back from the pool, and only + when every one is there is the file removed. Any failure leaves it in + place and exits 1, so the next run tries again. + + scripts/migrate_sick_history.py --dry-run # show what would happen + scripts/migrate_sick_history.py # do it + +``--history-file`` and ``--pool-dir`` point it at copies (for tests and +rehearsals); without ``--pool-dir`` the real pool is written. + +Output goes through ``_report`` rather than ``print``: ruff's T201 fix is +unsafe and deletes the whole call. +""" + +from __future__ import annotations + +import argparse +from datetime import date +import json +import logging +from pathlib import Path +import sys + +import sickdays + +DEFAULT_HISTORY_FILE = ( + Path(__file__).resolve().parent.parent / "screen_locker" / "sick_history.json" +) + +_logger = logging.getLogger(__name__) + + +class MigrationError(Exception): + """The history could not be read or the pool did not take every date.""" + + +def _report(message: str) -> None: + """Write one line to stdout, un-deletable by an autofixer.""" + sys.stdout.write(f"{message}\n") + + +def read_sick_days(path: Path) -> list[date]: + """The ``sick_days`` dates in ``path``, ascending. + + Raises: + MigrationError: The file is unreadable or holds something that is not + a list of ``YYYY-MM-DD`` dates. + """ + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + msg = f"could not read {path}: {exc}" + raise MigrationError(msg) from exc + days = raw.get("sick_days", []) if isinstance(raw, dict) else None + if not isinstance(days, list): + msg = f"{path} has no list of sick_days" + raise MigrationError(msg) + try: + return sorted({date.fromisoformat(str(entry)) for entry in days}) + except ValueError as exc: + msg = f"{path} holds a sick day that is not a YYYY-MM-DD date: {exc}" + raise MigrationError(msg) from exc + + +def migrate( + history: Path, paths: sickdays.Paths | None, *, dry_run: bool +) -> list[date]: + """Import ``history``'s dates into the pool, verify, remove ``history``. + + Returns: + The dates newly written (or, on a dry run, that would be). + + Raises: + MigrationError: Unreadable history, a refusing pool, or a date that + is not in the pool after the import. + """ + days = read_sick_days(history) + log_path = paths.log if paths is not None else None + missing = [d for d in days if d not in sickdays.sick_days(log_path=log_path)] + if dry_run: + return missing + try: + written = sickdays.import_legacy(missing, paths=paths) + except sickdays.SickDayError as exc: + msg = f"the sickdays pool refused the import: {exc}" + raise MigrationError(msg) from exc + absent = sorted(set(days) - sickdays.sick_days(log_path=log_path)) + if absent: + msg = f"not in the pool after the import: {', '.join(map(str, absent))}" + raise MigrationError(msg) + history.unlink() + return written + + +def main(argv: list[str] | None = None) -> int: + """Run the migration. Returns 0 when done or nothing to do, 1 on failure.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--history-file", type=Path, default=DEFAULT_HISTORY_FILE) + parser.add_argument("--pool-dir", type=Path, help="pool directory (default: real)") + parser.add_argument( + "--dry-run", action="store_true", help="show what would change, write nothing" + ) + args = parser.parse_args(argv) + history: Path = args.history_file + if not history.exists(): + _report(f"no {history} -- nothing to migrate") + return 0 + paths = sickdays.Paths.under(args.pool_dir) if args.pool_dir else None + try: + days = migrate(history, paths, dry_run=args.dry_run) + except MigrationError as exc: + _logger.warning( + "sick-history migration failed, %s left in place: %s", history, exc + ) + _report(f"FAILED: {exc} ({history} left in place)") + return 1 + listed = ", ".join(map(str, days)) or "none" + if args.dry_run: + _report(f"would import: {listed}; {history} would be removed") + else: + _report(f"imported: {listed}; every sick day is in the pool; {history} removed") + return 0 + + +if __name__ == "__main__": + logging.basicConfig(level=logging.WARNING) + sys.exit(main()) diff --git a/scripts/preview_shutdown.py b/scripts/preview_shutdown.py index 79c87e8..9b79a15 100755 --- a/scripts/preview_shutdown.py +++ b/scripts/preview_shutdown.py @@ -5,15 +5,18 @@ READ-ONLY on the real system: it never runs the sudo helper, never writes ``/etc``, ``shutdown_base.json``, ``grace_floor.json``, ``rest_days.json``, ``log.json`` or the real workout ledger. Simulations run the real code paths (TCX parsing + walk summing via the RunnerUp backfill, rest-day signing and -verification, ``_shutdown_target.derive``, the grace floor and its lift -bookkeeping) against a temporary directory, with an in-memory shutdown config. +verification, ``_shutdown_target.derive``, the grace and sick floors and their +lift bookkeeping, a temporary ``sickdays`` pool) against a temporary +directory, with an in-memory shutdown config. - python -m scripts.preview_shutdown # today, real ledgers + python -m scripts.preview_shutdown # today, real ledgers, as-is + # plus as sick / not sick python -m scripts.preview_shutdown --day D --walk 25,20 python -m scripts.preview_shutdown --day D --rest-day python -m scripts.preview_shutdown --day D --first-done 18:55 --done anki - python -m scripts.preview_shutdown --day D --first-done 18:55 --sick-day + python -m scripts.preview_shutdown --day D --walk 45 --sick-day python -m scripts.preview_shutdown --day D --sequence anki@21:00,reading@21:20 + python -m scripts.preview_shutdown --day D --sequence sick@10:00,workout@18:00 Run it with ``PYTHONPATH=~/src/utils/earned_time`` to preview the ladder (earned_time working tree) instead of the installed version. @@ -22,6 +25,7 @@ Run it with ``PYTHONPATH=~/src/utils/earned_time`` to preview the ladder from __future__ import annotations import argparse +from dataclasses import replace from datetime import date, datetime, time, timedelta from pathlib import Path import sys @@ -29,7 +33,7 @@ import tempfile import earned_time -from screen_locker import _earned, _grace_floor, _shutdown +from screen_locker import _earned, _grace_floor, _shutdown, _sick_floor from screen_locker._constants import SHUTDOWN_CONFIG_FILE from screen_locker._day import today_str from screen_locker._earned import hhmm @@ -40,18 +44,20 @@ from screen_locker._shutdown_target import ( Target, day_credit_count, derive, - gather, + gather_inputs, ) +from screen_locker._sick import is_sick from scripts._preview_sim import ( + SICK_STEP, SimRunnerUp, SimShutdown, - sick_history, + sick_pool, stamp, + use_temp_pool, write_walk, ) _LOG = Path(__file__).resolve().parents[1] / "screen_locker" / "log.json" -_SICK_STEP, _SICK_FLOOR = 60, 18 * 60 # _shutdown's sick-day step and floor def _say(text: str) -> None: @@ -71,8 +77,9 @@ def _report(label: str, target: Target) -> None: grace = hhmm(target.grace) if target.grace is not None else "-" _say(f"[{label}] {res.day} base {hhmm(res.base.shutdown_minutes)} | {terms}") _say( - f" rest_day={target.rest_day} earned={hhmm(target.earned)} " - f"first_done={first} grace={grace} -> SHUTDOWN {hhmm(target.minutes)}" + f" rest_day={target.rest_day} sick={res.sick} earned={hhmm(target.earned)} " + f"first_done={first} grace={grace} lift={target.lift} " + f"-> SHUTDOWN {hhmm(target.minutes)}" ) @@ -80,6 +87,7 @@ def _simulate(args: argparse.Namespace, tmp: Path) -> None: day = date.fromisoformat(args.day) # Every ledger (the workout one included) resolves under the temp dir. vars(_earned)["LEDGER_HOME"] = tmp + use_temp_pool(tmp) log = tmp / "log.json" if args.walk: drop = tmp / "drop" @@ -104,36 +112,35 @@ def _simulate(args: argparse.Namespace, tmp: Path) -> None: day_credit_count(log, day), rest_day=rest, first_done=min(times, default=None), - sick_day=sick_history(day, tmp, sick_day=args.sick_day), + sick_day=sick_pool(day, sick_day=args.sick_day), ) ) _report("simulated", target) - if args.sick_day: - # What the lock screen's sick-day choice then does to that (the - # existing _apply_earlier_shutdown rule); no grace floor lifts it back. - after = max(_SICK_FLOOR, target.minutes - _SICK_STEP) - _say(f" sick-day step at the lock screen -> SHUTDOWN {hhmm(after)}") def _sequence(args: argparse.Namespace, tmp: Path) -> None: day = date.fromisoformat(args.day) - for module in (_grace_floor, _shutdown): + for module in (_grace_floor, _shutdown, _sick_floor): vars(module)["today_str"] = lambda: args.day - vars(_grace_floor)["GRACE_STATE_FILE"] = tmp / "grace_floor.json" + vars(module)["GRACE_STATE_FILE"] = tmp / "grace_floor.json" + use_temp_pool(tmp) done = {e.name: False for e in _earned.flat_earners()} start = derive(DayInputs(day, done)) sim = SimShutdown(start.minutes) - _say(f"[sequence] {day} start {hhmm(start.minutes)} (nothing done)") + _say(f"[sequence] {day} start {hhmm(start.minutes)} (nothing done, not sick)") workouts, first = 0, None for step in args.sequence.split(","): name, _, at = step.partition("@") - first = min(first or stamp(day, at), stamp(day, at)) if name == earned_time.WORKOUT.name: workouts += 1 - else: + elif name != SICK_STEP: done[name] = True + if name != SICK_STEP: + first = min(first or stamp(day, at), stamp(day, at)) sim.credit(name, day, first) - want = derive(DayInputs(day, done, workouts, first_done=first)) + want = derive( + DayInputs(day, done, workouts, first_done=first, sick_day=is_sick(day)) + ) verdict = "" if sim.config[0] == want.minutes else " MISMATCH" _say( f" {name}@{at}: config {hhmm(sim.config[0])} " @@ -151,6 +158,7 @@ def main() -> int: parser.add_argument("--first-done", help="HH:MM a ledger earner was first done") parser.add_argument("--done", help="flat earners done, e.g. anki,leetcode") parser.add_argument("--sequence", help="name@HH:MM,... live passes in order") + parser.add_argument("--log", type=Path, default=_LOG, help="log.json (read-only)") args = parser.parse_args() ladder = getattr(earned_time, "on_ladder", None) _say(f"earned_time from {Path(earned_time.__file__).parent}") @@ -162,9 +170,12 @@ def main() -> int: _say( f" live config: {tuple(hhmm(m) for m in live) if live else 'unreadable'}" ) - target = gather(today, _LOG) + inputs = gather_inputs(today, args.log) + target = derive(inputs) _report("today, real ledgers", target) _say(f" expected config after the next reset: {hhmm(target.minutes)}") + for label, sick in (("as sick", True), ("as not sick", False)): + _report(f"today {label}", derive(replace(inputs, sick_day=sick))) return 0 day = date.fromisoformat(args.day) _say(f" ladder in force on {day}: {ladder(day) if ladder else False}") diff --git a/scripts/revoke_sick_day.py b/scripts/revoke_sick_day.py deleted file mode 100755 index 932a5aa..0000000 --- a/scripts/revoke_sick_day.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -"""Revoke a sick day that a later-verified workout disproves. - -On 2026-06-12 and 2026-08-24 a real workout was completed and the locker could -not see it, so a sick day was spent to get the screen back. Once the workout is -recovered, the penalty has to come off: the date leaves ``sick_days`` and the -debt it incurred is returned. - -What this deliberately does NOT do is edit the justification entry. Those are -HMAC-signed over the whole entry (``gatelock.log_integrity.compute_entry_hmac``), -so mutating one to add a ``revoked`` flag would make it fail verification and -read as tampering -- the opposite of the intent. Instead the revocation is -recorded in a sibling ``revocations`` list, keyed by date, naming the workout -that disproved it. The signed history stays verifiable; the correction sits -beside it, which is how an audit trail is supposed to work. - -Usage: - python3 scripts/revoke_sick_day.py --date 2026-08-24 - python3 scripts/revoke_sick_day.py --date 2026-08-24 --date 2026-06-12 -""" - -from __future__ import annotations - -import argparse -from datetime import UTC, datetime -import json -import logging -from pathlib import Path -import sys - -REPO_ROOT = Path(__file__).resolve().parent.parent -SICK_HISTORY_FILE = REPO_ROOT / "screen_locker" / "sick_history.json" -LOG_FILE = REPO_ROOT / "screen_locker" / "log.json" - -# Failures are logged as well as printed: this script rewrites sick-day -# history, so a read/write error that only reaches stdout can leave the -# revocation half-applied while the surrounding script reports success. -_logger = logging.getLogger(__name__) - - -def _report(message: str) -> None: - """Write one line to stdout (matches the other scripts in this directory).""" - sys.stdout.write(f"{message}\n") - - -def _workout_id_for(log: dict, date: str) -> str | None: - """Return the id of a counted workout on ``date``, if one exists.""" - for entry in log.get(date) or []: - workout_id = entry.get("workout_id") - if workout_id: - return str(workout_id) - return None - - -def revoke(history: dict, log: dict, date: str) -> str: - """Apply one revocation to ``history`` in place; return a status line. - - Idempotent: re-running never double-refunds the debt, because the date is - checked against the existing revocations first. - """ - revocations = history.setdefault("revocations", []) - if any(entry.get("date") == date for entry in revocations): - return f" {date}: already revoked — nothing to do" - - was_sick_day = date in history.get("sick_days", []) - workout_id = _workout_id_for(log, date) - - if not was_sick_day and workout_id is None: - # 2026-06-12 is exactly this case: a justification exists but the date - # never made it into sick_days, and the log no longer reaches back that - # far. Record the correction anyway -- the justification is the thing - # being annotated, and silence would leave it looking uncontested. - revocations.append( - { - "date": date, - "revoked_at": datetime.now(tz=UTC).isoformat(), - "reason": ( - "workout was completed but the locker could not read it; " - "no local log entry survives for this date" - ), - "workout_id": None, - } - ) - return f" {date}: annotated (no sick_days entry, no surviving log record)" - - if was_sick_day: - history["sick_days"] = [d for d in history["sick_days"] if d != date] - # The debt was incurred by taking the sick day; taking it back returns - # the debt with it. Floor at zero so a double-run cannot go negative. - history["debt"] = max(0, int(history.get("debt", 0)) - 1) - - revocations.append( - { - "date": date, - "revoked_at": datetime.now(tz=UTC).isoformat(), - "reason": ( - "workout verified after the fact; the sick day was spent only " - "because the locker could not read the synced workout" - ), - "workout_id": workout_id, - } - ) - detail = f"workout_id={workout_id}" if workout_id else "no log record found" - return f" {date}: revoked ({detail}), debt now {history['debt']}" - - -def main() -> int: - """Revoke every ``--date`` given, preserving the signed justifications.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--date", action="append", required=True, help="YYYY-MM-DD (repeatable)" - ) - parser.add_argument("--history-file", default=str(SICK_HISTORY_FILE)) - parser.add_argument("--log-file", default=str(LOG_FILE)) - args = parser.parse_args() - - history_path = Path(args.history_file) - try: - history = json.loads(history_path.read_text()) - except (OSError, json.JSONDecodeError) as exc: - _logger.exception("Could not read %s — no sick day was revoked", history_path) - _report(f"FAIL: could not read {history_path}: {exc}") - return 1 - - log_path = Path(args.log_file) - try: - log = json.loads(log_path.read_text()) if log_path.is_file() else {} - except (OSError, json.JSONDecodeError) as exc: - _logger.exception( - "Could not read %s — cannot cite a workout id, so nothing was revoked", - log_path, - ) - _report(f"FAIL: could not read {log_path}: {exc}") - return 1 - - before = int(history.get("debt", 0)) - _report(f"Sick days before: {history.get('sick_days')} (debt {before})") - for date in args.date: - _report(revoke(history, log, date)) - - # Written with json.dump rather than _sick_tracker.save_history because - # that helper rebuilds the payload from a fixed field list and would drop - # the revocations key on the next save. - try: - history_path.write_text(json.dumps(history, indent=2) + "\n") - except OSError as exc: - _logger.exception( - "Could not write %s — the revocation was NOT persisted", - history_path, - ) - _report(f"FAIL: could not write {history_path}: {exc}") - return 1 - - _report(f"Sick days after: {history.get('sick_days')} (debt {history['debt']})") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/verify_screen_fits.py b/scripts/verify_screen_fits.py index 08af791..85251cb 100755 --- a/scripts/verify_screen_fits.py +++ b/scripts/verify_screen_fits.py @@ -9,21 +9,15 @@ The runtime half of this guarantee is the ``warning`` ``ScrollableSurface.finalize`` logs; this script is the half that runs before the code ships, so a layout change cannot quietly reintroduce overflow. -``StatusWindow`` is deliberately absent: it is a normal, resizable, -dismissible window rather than a lock surface, so a user who needs more of it -can resize it. Every screen that grabs the display is here. +``StatusWindow`` is absent: it is a resizable window, not a lock surface. -Not a pytest test because ``screen_locker/tests/conftest.py`` replaces the -whole ``tk`` module for every test so the suite can never reach a display, and -widget heights can only be measured by really rendering them. Like -``verify_lock_popup_safety.py``, this re-executes itself under Xvfb: +Not a pytest test: conftest replaces ``tk`` so no test reaches a display, and +heights need real rendering. It re-executes itself under Xvfb: cd ~/src/screen-locker && python3 -m scripts.verify_screen_fits -Run as a *module*, not a path: ``-m`` puts the repo root on ``sys.path`` so -``screen_locker`` resolves from the checkout. Running it as a path only works -where the package happens to be pip-installed, which is why it passed here and -died in CI. +Run as a *module*: ``-m`` puts the repo root on ``sys.path``, so +``screen_locker`` resolves from the checkout (as a path it died in CI). """ from __future__ import annotations @@ -40,7 +34,6 @@ from gatelock import LockConfig, measure_fit, report_fit from screen_locker._constants import MANUAL_WORKOUT_REFLECTION_MIN_CHARS from screen_locker._heat_skip import build_heat_skip_content from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin -from screen_locker._sick_dialog import SickDialogMixin from screen_locker._surface_group import FrameGroup from screen_locker._ui_flows import UIFlowsMixin from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin @@ -72,12 +65,17 @@ LONG_MESSAGE = ( "Go do your workout first!" ) +# sickdays' FreeDayConflictError text: its longest refusal. +SICK_REFUSAL = ( + "2026-10-16 is a free day -- release it with 'freedays release' before " + "marking it sick" +) + class ScreenHost( UIWidgetsMixin, UIFlowsMixin, UIFlowsRelaxedMixin, - SickDialogMixin, ManualWorkoutDialogMixin, ): """The real screen-painting mixins, over a throwaway surface. @@ -94,7 +92,7 @@ class ScreenHost( """Point the mixins at one surface's content frame.""" self.root = surface.content.winfo_toplevel() self.demo_mode = False - self.verify_only = False + self._relaxed_day_mode = False # Deliberately NOT the real log.json: is_budget_exhausted() reads it, # and the real file's content depends on today's actual usage -- # exhausting the manual-workout budget in production made this crash @@ -157,18 +155,22 @@ class ScreenHost( ) ) - def paint_sick(self) -> None: - """Paint the sick-day justification screen.""" - self._show_sick_justification() + def paint_sick_refused(self) -> None: + """Paint the screen shown when today cannot be marked sick.""" + self._show_sick_refused(SICK_REFUSAL) + + def paint_phone_penalty(self) -> None: + """Paint the no-phone penalty screen (countdown + I'm sick).""" + self._show_phone_penalty(LONG_MESSAGE) + + def paint_relaxed_prompt(self) -> None: + """Paint the optional Tue-Thu prompt (Skip / Log / I'm sick).""" + self._start_relaxed_day_flow() def paint_relaxed_retry(self) -> None: """Paint the relaxed-day (Tue-Thu) retry screen.""" self._show_relaxed_retry(LONG_MESSAGE, "no_workout") - def paint_verify_retry(self) -> None: - """Paint the verify-mode retry screen.""" - self._show_verify_retry(LONG_MESSAGE) - @dataclass(frozen=True) class Screen: @@ -194,10 +196,11 @@ SCREENS: tuple[Screen, ...] = ( Screen("no-workout-retry", ScreenHost.paint_retry), Screen("manual-workout-form", ScreenHost.paint_manual_form), Screen("manual-workout-form+error", ScreenHost.paint_manual_form_with_error), - Screen("sick-justification", ScreenHost.paint_sick), + Screen("sick-refused", ScreenHost.paint_sick_refused), + Screen("phone-penalty", ScreenHost.paint_phone_penalty), + Screen("relaxed-day-prompt", ScreenHost.paint_relaxed_prompt), Screen("lockout", ScreenHost.lockout), Screen("relaxed-day-retry", ScreenHost.paint_relaxed_retry), - Screen("verify-retry", ScreenHost.paint_verify_retry), ) # Screens built without a ScreenHost, measured through their own builder. diff --git a/web/src/format.ts b/web/src/format.ts index d9a65fb..6541aec 100644 --- a/web/src/format.ts +++ b/web/src/format.ts @@ -49,10 +49,9 @@ export function describeReason(reason: string): string { early_bird_banked: 'Skipped — early-bird marker banked', early_bird_auto_upgraded: 'Skipped — early-bird workout confirmed', sick_day: 'Skipped — sick day', - sick_day_auto_upgraded: 'Skipped — sick day, workout confirmed', + sick_day_marked: 'Unlocked — marked sick at the lock screen', wake_alarm_skip: 'Skipped — earned by the wake alarm', heat_skip: 'Skipped — too hot', - no_sick_day_to_verify: 'Nothing to verify', mode_makes_no_decision: 'No decision (status or sync-only run)', } return known[reason] ?? reason.replace(/_/g, ' ')