mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 16:48:53 +01:00
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:
parent
ef477d644b
commit
7d8daca111
@ -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
|
||||
|
||||
|
||||
@ -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"` +
|
||||
|
||||
19
README.md
19
README.md
@ -1,8 +1,23 @@
|
||||
# screen-locker
|
||||
|
||||
Tkinter/systemd screen locker with workout tracking, sick-day management, and
|
||||
Tkinter/systemd screen locker with workout tracking, shared sick days, and
|
||||
wake-alarm integration. It enforces a workout cadence by locking the screen
|
||||
until a RunnerUp-verified (or explicitly justified) workout is logged.
|
||||
until a verified (RunnerUp, StrongLifts) or budgeted manual workout is logged.
|
||||
|
||||
## Sick days
|
||||
|
||||
A sick day is a date in the fleet-wide [`sickdays`](https://github.com/kuhyx/utils/tree/main/sickdays)
|
||||
pool, nothing else: no budget, countdown, justification or debt. The lock
|
||||
screen's **I'm sick** button (retry screen, no-phone screen and the Tue-Thu
|
||||
prompt) marks today with one tap and unlocks; `sickdays add` or the phone do
|
||||
the same. On a sick day the workout lock and the grace floor stand down,
|
||||
wake-alarm's workday penalty is void, and shutdown is
|
||||
`max(20:00, the normal ladder)` capped at 23:00 (`earned_time.resolve(...,
|
||||
sick=True)`). A day marked after its daily reset is lifted to 20:00 by the
|
||||
next 5-minute pass (`_sick_floor`); a workout that day still earns normally
|
||||
and is paid out of that lift first, so it lands on the ladder value (e.g.
|
||||
20:40), never 20:00 plus its whole reward. `install.sh` migrates the old
|
||||
`sick_history.json` dates into the pool once (`scripts/migrate_sick_history.py`).
|
||||
|
||||
## Shutdown time
|
||||
|
||||
|
||||
10
install.sh
10
install.sh
@ -138,6 +138,16 @@ if [[ -d "$GATELOCK_DIR" ]]; then
|
||||
fi
|
||||
echo " ok: screen_locker and gatelock both import under $PROD_PYTHON"
|
||||
|
||||
# One-shot, idempotent: the old sick_history.json's dates move into the shared
|
||||
# sickdays pool and the file is removed once they read back. Rerunning (or a
|
||||
# machine that never had the file) is a no-op. A failure keeps the file and
|
||||
# is retried by the next install; it does not block the locker itself.
|
||||
echo "Migrating the legacy sick history into the sickdays pool..."
|
||||
if ! "$PROD_PYTHON" "$REPO_DIR/scripts/migrate_sick_history.py"; then
|
||||
echo " WARNING: sick-history migration failed (file kept); rerun" >&2
|
||||
echo " $REPO_DIR/scripts/migrate_sick_history.py" >&2
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Done ==="
|
||||
echo "The locker is live. Check when it next fires with:"
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
"""Mixin: auto-upgrade early_bird/sick_day pending states via phone or RunnerUp.
|
||||
"""Mixin: the today-state rungs of the lock ladder, and the early-bird upgrade.
|
||||
|
||||
Neither early_bird (a same-day pending marker, see ``_early_bird.py``) nor
|
||||
sick_day (tracked in ``sick_history.json`` via ``_sick_tracker.py``) live in
|
||||
log.json — this module only checks their pending state and, on
|
||||
success, writes the *real* outcome (phone_verified/runnerup_verified) there.
|
||||
early_bird is a same-day pending marker (``_early_bird.py``), not a log.json
|
||||
entry — this module only checks its pending state and, on success, writes
|
||||
the *real* outcome (phone_verified/runnerup_verified) there.
|
||||
|
||||
A sick day is a date in the shared ``sickdays`` pool (``_sick.py``): the
|
||||
lock stands down for it and nothing is written. A workout done on a sick
|
||||
day still reaches the log through the ordinary sync paths and earns
|
||||
normally, so there is no sick-day upgrade any more.
|
||||
|
||||
The upgrade credits through ``_apply_workout_credit`` -- save first, reward
|
||||
only if the entry was actually appended. It used to push the shutdown hour
|
||||
@ -18,10 +22,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from screen_locker import _sick_tracker
|
||||
from screen_locker._decision_log import LockDecision, record_decision
|
||||
from screen_locker._decision_reasons import reasons_extra
|
||||
from screen_locker._morning_session import has_workout_skip_today
|
||||
from screen_locker._sick import is_sick
|
||||
from screen_locker._weekly_check import has_weekly_minimum, is_relaxed_day
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@ -79,15 +83,11 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
||||
"""
|
||||
|
||||
def _is_sick_day_today(self) -> bool:
|
||||
"""Check if today is marked as a sick day in sick_history.json."""
|
||||
return _sick_tracker.is_sick_day(_sick_tracker.load_history())
|
||||
"""Whether today is a date in the shared sickdays pool."""
|
||||
return is_sick()
|
||||
|
||||
def _check_early_exits(self, *, verify_only: bool) -> None:
|
||||
def _check_early_exits(self) -> None:
|
||||
"""Check startup conditions and exit early when appropriate."""
|
||||
if verify_only:
|
||||
if not self._is_sick_day_today():
|
||||
_skip("no_sick_day_to_verify", "No sick day logged today.")
|
||||
return
|
||||
self._check_non_verify_exits()
|
||||
|
||||
def _check_today_state_exits(self) -> bool:
|
||||
@ -119,17 +119,11 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
||||
**self._other_conditions("early_bird_window_active"),
|
||||
)
|
||||
elif self._is_sick_day_today():
|
||||
if self._try_auto_upgrade_sick_day():
|
||||
_skip(
|
||||
"sick_day_auto_upgraded",
|
||||
"Auto-upgraded today's sick_day entry to phone_verified.",
|
||||
)
|
||||
else:
|
||||
_skip(
|
||||
"sick_day",
|
||||
"Sick day already logged today.",
|
||||
**self._other_conditions("sick_day"),
|
||||
)
|
||||
_skip(
|
||||
"sick_day",
|
||||
"Today is a sick day (sickdays pool).",
|
||||
**self._other_conditions("sick_day"),
|
||||
)
|
||||
elif self.has_logged_today():
|
||||
_skip(
|
||||
"workout_logged_today",
|
||||
@ -152,44 +146,6 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _try_auto_upgrade_sick_day(self) -> bool:
|
||||
"""Upgrade sick_day entry when phone or RunnerUp detects a valid workout."""
|
||||
try:
|
||||
status, message = self._verify_phone_workout()
|
||||
except (OSError, RuntimeError) as exc:
|
||||
_logger.warning(
|
||||
"Sick-day auto-upgrade could not reach the phone (%s) — today's "
|
||||
"sick_day entry stays unverified; trying RunnerUp next",
|
||||
exc,
|
||||
)
|
||||
status, message = "error", str(exc)
|
||||
if status == "verified":
|
||||
self.workout_data["type"] = "phone_verified"
|
||||
self.workout_data["source"] = message
|
||||
self.workout_data["after_sick_day"] = "true"
|
||||
self._apply_workout_credit()
|
||||
return True
|
||||
_logger.info("Auto-upgrade phone skipped (%s), trying RunnerUp...", status)
|
||||
try:
|
||||
runnerup_status, runnerup_msg = self._verify_runnerup_workout()
|
||||
except (OSError, RuntimeError) as exc:
|
||||
_logger.warning(
|
||||
"Sick-day auto-upgrade could not read RunnerUp either (%s) — "
|
||||
"today stays a sick_day, NOT upgraded to a verified workout",
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
if runnerup_status != "verified":
|
||||
_logger.info(
|
||||
"Auto-upgrade RunnerUp skipped (%s): %s", runnerup_status, runnerup_msg
|
||||
)
|
||||
return False
|
||||
self.workout_data["type"] = "runnerup_verified"
|
||||
self.workout_data["source"] = runnerup_msg
|
||||
self.workout_data["after_sick_day"] = "true"
|
||||
self._apply_workout_credit()
|
||||
return True
|
||||
|
||||
def _try_auto_upgrade_early_bird(self) -> bool:
|
||||
"""Try phone then RunnerUp to upgrade an early_bird log entry."""
|
||||
try:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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).",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
@ -4,8 +4,9 @@
|
||||
(:mod:`screen_locker._shutdown_base`) and the read-only preview
|
||||
(``scripts/preview_shutdown.py``), so a dry run exercises the real sum::
|
||||
|
||||
earned = earned_time.resolve(answers) # base + every earner, capped
|
||||
target = min(ceiling, max(earned, grace floor))
|
||||
earned = earned_time.resolve(answers, sick=...) # base + every earner, capped;
|
||||
# sick: at least 20:00
|
||||
target = min(ceiling, max(earned, grace floor)) # no grace floor when sick
|
||||
|
||||
A declared rest day answers the workout with at least one unit, i.e. its
|
||||
first-unit bonus; a real workout on top of it earns only what a further unit
|
||||
@ -20,9 +21,10 @@ from typing import TYPE_CHECKING
|
||||
import earned_time
|
||||
|
||||
from screen_locker._earned import ceiling, first_credits, gate_answers, registry
|
||||
from screen_locker._grace_floor import first_done_at, grace_for, sick_on
|
||||
from screen_locker._grace_floor import first_done_at, grace_for
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._rest_day import is_rest_day
|
||||
from screen_locker._sick import is_sick
|
||||
from screen_locker._weekly_check import count_day_credits
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -36,7 +38,8 @@ class Target:
|
||||
"""One day's derived shutdown and what made it.
|
||||
|
||||
Attributes:
|
||||
resolution: ``earned_time``'s sum (base + terms, capped).
|
||||
resolution: ``earned_time``'s sum (base + terms, capped; on a sick
|
||||
day raised to the 20:00 sick floor).
|
||||
rest_day: Whether the day is a declared rest day.
|
||||
first_done_at: Unix time the day's first earner was done, if any.
|
||||
grace: The grace floor in minutes, or ``None``.
|
||||
@ -51,12 +54,16 @@ class Target:
|
||||
|
||||
@property
|
||||
def earned(self) -> int:
|
||||
"""The earned shutdown, before the grace floor."""
|
||||
return self.resolution.shutdown_minutes
|
||||
"""The earned shutdown: base plus terms, before any floor."""
|
||||
return self.resolution.shutdown_minutes - self.resolution.sick_lift_minutes
|
||||
|
||||
@property
|
||||
def lift(self) -> int:
|
||||
"""How far the grace floor lifts the earned shutdown."""
|
||||
"""How far the floors (grace or sick) lift the earned shutdown.
|
||||
|
||||
Saved as the day's lift (``_grace_floor.save_lift``), so a credit that
|
||||
lands later is paid out of it first instead of stacking on 20:00.
|
||||
"""
|
||||
return self.minutes - self.earned
|
||||
|
||||
|
||||
@ -76,7 +83,8 @@ class DayInputs:
|
||||
workout_credits: The log's distinct workout credits.
|
||||
rest_day: Declared rest day.
|
||||
first_done: Unix time the day's first earner was done, if any.
|
||||
sick_day: Sick day: the sick-day shutdown wins, no grace floor.
|
||||
sick_day: Sick day (``sickdays``): shutdown is at least 20:00
|
||||
(``earned_time.resolve(..., sick=True)``) and no grace floor.
|
||||
first_credits: Each penalised gate's first real credit
|
||||
(:func:`~screen_locker._earned.first_credits`); ``None`` resolves
|
||||
on ``penalty_from`` alone, as before earned_time 0.6.
|
||||
@ -102,10 +110,11 @@ def derive(inputs: DayInputs) -> Target:
|
||||
# day's registry, so the earners asked and the earners summed match.
|
||||
earners = registry(day)
|
||||
starts = inputs.first_credits
|
||||
sick = inputs.sick_day
|
||||
resolution = (
|
||||
earned_time.resolve(answers, day, earners)
|
||||
earned_time.resolve(answers, day, earners, sick=sick)
|
||||
if starts is None
|
||||
else earned_time.resolve(answers, day, earners, first_credits=starts)
|
||||
else earned_time.resolve(answers, day, earners, first_credits=starts, sick=sick)
|
||||
)
|
||||
grace = None if inputs.sick_day else grace_for(inputs.first_done, day)
|
||||
minutes = resolution.shutdown_minutes
|
||||
@ -129,17 +138,20 @@ def day_credit_count(log_file: Path | None, day: date) -> int:
|
||||
return count_day_credits(iso, [e for e in entries if isinstance(e, dict)])
|
||||
|
||||
|
||||
def gather_inputs(day: date, log_file: Path | None) -> DayInputs:
|
||||
"""Read ``day``'s real inputs: ledgers, log, rest days, sick pool."""
|
||||
rest_day = is_rest_day(day)
|
||||
return DayInputs(
|
||||
day=day,
|
||||
flat=gate_answers(day),
|
||||
workout_credits=day_credit_count(log_file, day),
|
||||
rest_day=rest_day,
|
||||
first_done=first_done_at(day, log_file, rest_day=rest_day),
|
||||
sick_day=is_sick(day),
|
||||
first_credits=first_credits(registry(day), day),
|
||||
)
|
||||
|
||||
|
||||
def gather(day: date, log_file: Path | None) -> Target:
|
||||
"""Derive ``day``'s target from the real ledgers, log and rest days."""
|
||||
rest_day = is_rest_day(day)
|
||||
return derive(
|
||||
DayInputs(
|
||||
day=day,
|
||||
flat=gate_answers(day),
|
||||
workout_credits=day_credit_count(log_file, day),
|
||||
rest_day=rest_day,
|
||||
first_done=first_done_at(day, log_file, rest_day=rest_day),
|
||||
sick_day=sick_on(day),
|
||||
first_credits=first_credits(registry(day), day),
|
||||
)
|
||||
)
|
||||
return derive(gather_inputs(day, log_file))
|
||||
|
||||
82
screen_locker/_sick.py
Normal file
82
screen_locker/_sick.py
Normal 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.")
|
||||
@ -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}"
|
||||
)
|
||||
@ -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()
|
||||
@ -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)
|
||||
# ------------------------------------------------------------------
|
||||
79
screen_locker/_sick_floor.py
Normal file
79
screen_locker/_sick_floor.py
Normal 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
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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 ===")
|
||||
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
|
||||
@ -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}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
"""The success screen shown once a workout has been credited.
|
||||
|
||||
Split out of ``screen_lock`` so that module stays inside the repo's 400-line
|
||||
cap; this is pure view code (paint the credit, then hand off to the
|
||||
commitment prompt or the close), which is exactly the kind of thing the other
|
||||
``_ui_*`` mixins already own.
|
||||
cap; this is pure view code (paint the credit, then close), which is exactly
|
||||
the kind of thing the other ``_ui_*`` mixins already own.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -14,14 +13,9 @@ from screen_locker._extra_benefits import current_streak
|
||||
|
||||
_UNLOCK_DELAY_MS = 1500
|
||||
|
||||
# Workout kinds that carry a commitment prompt on the way out: they are the
|
||||
# ones the user actually performed today, so "will you work out tomorrow?" is
|
||||
# a question worth asking.
|
||||
_COMMITTABLE = ("phone_verified", "runnerup_verified", "manual_workout")
|
||||
|
||||
|
||||
class UnlockViewMixin:
|
||||
"""Paints the unlock screen and routes to whatever follows it."""
|
||||
"""Paints the unlock screen, then closes it."""
|
||||
|
||||
def unlock_screen(self) -> None:
|
||||
"""Apply workout credit and display the success message."""
|
||||
@ -47,14 +41,6 @@ class UnlockViewMixin:
|
||||
role="subtitle",
|
||||
color=self._colors.palette.warning,
|
||||
)
|
||||
if credit.new_debt is not None:
|
||||
self._text(
|
||||
f"Workout debt: {credit.new_debt}",
|
||||
role="subtitle",
|
||||
color=self._colors.palette.warning
|
||||
if credit.new_debt > 0
|
||||
else self._colors.palette.muted,
|
||||
)
|
||||
streak = current_streak(EXTRA_BENEFITS_FILE)
|
||||
if streak >= 1:
|
||||
self._text(
|
||||
@ -63,10 +49,4 @@ class UnlockViewMixin:
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
self._text("Screen Unlocked!", role="display", pad="md")
|
||||
if self.workout_data.get("type") in _COMMITTABLE:
|
||||
self.root.after(
|
||||
_UNLOCK_DELAY_MS,
|
||||
lambda: self._show_commitment_prompt(on_done=self.close),
|
||||
)
|
||||
else:
|
||||
self.root.after(_UNLOCK_DELAY_MS, self.close)
|
||||
self.root.after(_UNLOCK_DELAY_MS, self.close)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
24
screen_locker/tests/_sick_helpers.py
Normal file
24
screen_locker/tests/_sick_helpers.py
Normal 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
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}))
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)."""
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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)."""
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
139
screen_locker/tests/test_sick_button.py
Normal file
139
screen_locker/tests/test_sick_button.py
Normal 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
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -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)
|
||||
@ -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"
|
||||
)
|
||||
119
screen_locker/tests/test_sick_floor.py
Normal file
119
screen_locker/tests/test_sick_floor.py
Normal 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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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),
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user