sick days: one shared sickdays pool, 20:00 shutdown floor, one-tap lock button

A sick day is now a date in the shared sickdays pool (utils sickdays 0.1.0)
and nothing else. Removed: the 7/30/90-day budget, the escalating lockout
countdown, the justification form, the commitment prompt and its double
debt, the workout-debt counter, the -60 min shutdown cut and its
sick_day_state.json, the post-sick-day --verify-workout mode, the sick
auto-upgrade, scripts/revoke_sick_day.py and their tests.

- Lock screen: "I'm sick" on the retry, no-phone and both Tue-Thu screens;
  one tap -> sickdays.add_sick_day(today) -> sick floor -> best-effort
  background sickdays.sync_quietly -> unlock. Refusals (free day,
  unreadable pool) are shown and the lock stays.
- Shutdown: the reset resolves with earned_time.resolve(..., sick=True)
  (max(20:00, ladder), capped 23:00) and saves the sick lift with the grace
  lift, so later credits absorb it instead of stacking on 20:00. A day
  marked after the reset is lifted by the new _sick_floor live pass (every
  5-min tick, --sync-only, --apply-bonuses, and the tap itself).
- Workout lock and grace floor stand down on a sick day; workouts that day
  credit normally through the sync paths.
- wake-alarm workday_penalty.json is void when its issued_date or
  penalty_date is a sick day.
- Status window, i3 line, CLI --status, MCP flags and web reasons read
  sickdays.
- scripts/migrate_sick_history.py imports sick_history.json's dates via
  sickdays.import_legacy, verifies and removes the file; idempotent, run by
  install.sh.
- Pins: earned-time 0.11.0, freedays 0.4.0, sickdays 0.1.0 (tags pending);
  pre-commit 4.7.0, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 13:30:39 +02:00
parent ef477d644b
commit 7d8daca111
123 changed files with 1153 additions and 4507 deletions

View File

@ -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

View File

@ -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"` +

View File

@ -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

View File

@ -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:"

View File

@ -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"]

View File

@ -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

View File

@ -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,15 +119,9 @@ 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.",
"Today is a sick day (sickdays pool).",
**self._other_conditions("sick_day"),
)
elif self.has_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:

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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).",

View File

@ -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.

View File

@ -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

View File

@ -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:

View File

@ -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(),
}

View File

@ -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,
)

View File

@ -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

View File

@ -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.

View File

@ -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))

View File

@ -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)

View File

@ -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(day: date, log_file: Path | None) -> Target:
"""Derive ``day``'s target from the real ledgers, log and rest days."""
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 derive(
DayInputs(
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=sick_on(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."""
return derive(gather_inputs(day, log_file))

82
screen_locker/_sick.py Normal file
View File

@ -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.")

View File

@ -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}"
)

View File

@ -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()

View File

@ -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 <Tab> inside a Text (inserts a tab, refocuses itself) and
# no-ops <Shift-Tab>, 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)
# ------------------------------------------------------------------

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -16,7 +16,7 @@ from screen_locker._extra_benefits import (
has_extended_early_bird,
)
from screen_locker._log_io import load_workout_log
from screen_locker._sick_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 ===")

View File

@ -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(),
)

View File

@ -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}"
)

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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.",

View File

@ -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:

View File

@ -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)",

View File

@ -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"))

View File

@ -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 ``<Tab>`` insert a literal tab and refocus the widget, and
binds ``<Shift-Tab>`` 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.

View File

@ -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"]

View File

@ -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)

View File

@ -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(

View File

@ -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)

View File

@ -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,

View File

@ -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()

View File

@ -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)

View File

@ -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.

View File

@ -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)

View File

@ -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:

View File

@ -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.

View File

@ -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

View File

@ -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",
)

View File

@ -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",

View File

@ -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",

View File

@ -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:

View File

@ -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

View File

@ -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,
)

View File

@ -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"

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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 <Tab> 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 "<Tab>" in bound
assert "<Shift-Tab>" 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 "<<Paste>>" in bound
assert "<Control-v>" in bound

View File

@ -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}))

View File

@ -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:

View File

@ -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,

View File

@ -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

View File

@ -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}"

View File

@ -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")

View File

@ -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
):

View File

@ -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()

View File

@ -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,

View File

@ -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:

View File

@ -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,

View File

@ -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,

View File

@ -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()

View File

@ -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)

View File

@ -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)."""

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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."""

View File

@ -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)."""

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
# ---------------------------------------------------------------------------

View File

@ -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)

View File

@ -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"
)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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),

View File

@ -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",

Some files were not shown because too many files have changed in this diff Show More