From d9119a65822e60a985d940812a3bda8c15aefbf9 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 5 Jul 2026 20:08:52 +0200 Subject: [PATCH] Add manual (unverified) workout logging with evidence form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a rate-limited manual-workout entry point (StatusWindow + locked retry screen) for activities like table tennis that ADB/RunnerUp can't verify, gated by a 2/7d + 5/30d budget and a detailed evidence form rather than free text. Full credit toward the weekly minimum, debt clearing, and commitment prompt, matching phone/RunnerUp verification. Also stops tracking personal runtime state (workout_log.json, sick_history.json, extra_benefits_state.json, scheduled_skips.json, shutdown_base.json, early_bird_pending.json) in git — these are regenerated locally and never belong in a public/private repo's history. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AbPcvAvt1JhiRzEyPDE5dA --- .gitignore | 8 + .pre-commit-config.yaml | 3 + .../workstream-c-github-sync-workout-data.md | 153 +++++++ docs/todo/workstream-d-scoping-findings.md | 145 +++++++ .../workstream-d-shared-crdt-transport.md | 137 +++++++ pyproject.toml | 19 +- screen_locker/_compliance_state.py | 383 ++++++++++++++++++ screen_locker/_constants.py | 13 + screen_locker/_early_bird.py | 20 +- screen_locker/_extra_benefits.py | 25 +- screen_locker/_log_mixin.py | 57 ++- screen_locker/_manual_workout.py | 312 ++++++++++++++ screen_locker/_manual_workout_dialog.py | 357 ++++++++++++++++ screen_locker/_shutdown.py | 52 ++- screen_locker/_sick_dialog.py | 44 +- screen_locker/_status_data.py | 371 +++++++++++++++++ screen_locker/_ui_flows.py | 30 +- screen_locker/_ui_widgets.py | 43 ++ screen_locker/_weekly_check.py | 2 +- screen_locker/_workout_credit.py | 137 +++++++ screen_locker/screen_lock.py | 119 +++--- screen_locker/status_view.py | 352 ++++++++++++++++ screen_locker/tests/_status_view_helpers.py | 161 ++++++++ screen_locker/tests/conftest.py | 14 +- screen_locker/tests/test_compliance_state.py | 275 +++++++++++++ .../tests/test_compliance_state_part2.py | 203 ++++++++++ screen_locker/tests/test_early_bird.py | 20 +- screen_locker/tests/test_extra_benefits.py | 29 ++ screen_locker/tests/test_heat_skip.py | 66 +++ screen_locker/tests/test_init_and_log.py | 12 +- screen_locker/tests/test_manual_workout.py | 300 ++++++++++++++ .../tests/test_manual_workout_dialog.py | 209 ++++++++++ .../tests/test_manual_workout_features.py | 235 +++++++++++ .../tests/test_manual_workout_status_view.py | 181 +++++++++ .../tests/test_screen_lock_coverage_part2.py | 11 +- screen_locker/tests/test_status.py | 26 +- screen_locker/tests/test_status_data.py | 354 ++++++++++++++++ screen_locker/tests/test_status_view.py | 170 ++++++++ screen_locker/tests/test_status_view_part2.py | 222 ++++++++++ screen_locker/tests/test_temperature.py | 98 +++++ .../tests/test_weekly_logic_part2.py | 64 ++- .../android/app/src/main/AndroidManifest.xml | 2 - .../workout_app/lib/main.dart | 39 +- 43 files changed, 5249 insertions(+), 224 deletions(-) create mode 100644 docs/todo/workstream-c-github-sync-workout-data.md create mode 100644 docs/todo/workstream-d-scoping-findings.md create mode 100644 docs/todo/workstream-d-shared-crdt-transport.md create mode 100644 screen_locker/_compliance_state.py create mode 100644 screen_locker/_manual_workout.py create mode 100644 screen_locker/_manual_workout_dialog.py create mode 100644 screen_locker/_status_data.py create mode 100644 screen_locker/_workout_credit.py create mode 100644 screen_locker/status_view.py create mode 100644 screen_locker/tests/_status_view_helpers.py create mode 100644 screen_locker/tests/test_compliance_state.py create mode 100644 screen_locker/tests/test_compliance_state_part2.py create mode 100644 screen_locker/tests/test_heat_skip.py create mode 100644 screen_locker/tests/test_manual_workout.py create mode 100644 screen_locker/tests/test_manual_workout_dialog.py create mode 100644 screen_locker/tests/test_manual_workout_features.py create mode 100644 screen_locker/tests/test_manual_workout_status_view.py create mode 100644 screen_locker/tests/test_status_data.py create mode 100644 screen_locker/tests/test_status_view.py create mode 100644 screen_locker/tests/test_status_view_part2.py create mode 100644 screen_locker/tests/test_temperature.py diff --git a/.gitignore b/.gitignore index 22de16e..7fefd9c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,11 @@ coverage.lcov htmlcov/ *.log .DS_Store + +# Runtime state files — personal workout/health data, never public. +screen_locker/workout_log.json +screen_locker/sick_history.json +screen_locker/extra_benefits_state.json +screen_locker/scheduled_skips.json +screen_locker/shutdown_base.json +screen_locker/early_bird_pending.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dc3e7c1..3e336d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,6 +25,9 @@ repos: - id: debug-statements - id: name-tests-test args: [--pytest-test-first] + # Shared, non-test fixture helpers used by test_status_view*.py — + # deliberately not collected as tests (no test_/*_test naming). + exclude: ^screen_locker/tests/_status_view_helpers\.py$ - id: check-ast - id: mixed-line-ending args: [--fix=lf] diff --git a/docs/todo/workstream-c-github-sync-workout-data.md b/docs/todo/workstream-c-github-sync-workout-data.md new file mode 100644 index 0000000..e996f55 --- /dev/null +++ b/docs/todo/workstream-c-github-sync-workout-data.md @@ -0,0 +1,153 @@ +# Workstream C — GitHub-sync channel for phone workout data + +## Status + +**Blocked on Workstream D** (2026-07-04 user decision — see "Sequencing +decision" below). No code has been written for this workstream. The design +questions below (sync pattern, repo name, scope) have been pre-decided so the +eventual implementation pass doesn't need to re-litigate them, but +implementation itself waits until Workstream D's shared CRDT transport +library exists and screen-locker adopts it. + +## Context + +Screen-locker currently learns about phone-side workouts (the +`phone_verified` workout type) via `PhoneVerificationMixin` +(`screen_locker/_phone_verification.py`): ADB pull of +`workout_result.json` first (`_pull_workout_app_json`, checking +`WORKOUT_APP_JSON_REMOTES` in `_constants.py`), falling back to scanning the +local /24 subnet for the phone app's HTTP server on port 8765 +(`_fetch_http_workout`/`_scan_for_http_server`). Both mechanisms require the +phone and PC to be on the same network (USB or same LAN) at verification +time. + +This workstream adds a GitHub-sync transport as the **primary** channel for +this specific data (the phone app pushes its `workout_result.json` to a +private GitHub repo/gist; the PC pulls from there instead of needing +same-network ADB/HTTP), with the existing ADB pull / HTTP-scan mechanism +demoted to **fallback** (kept, not removed — it still works when the phone +is plugged in or on the same LAN, and needs no internet-dependent third +party). + +**Hard boundary — do not cross this:** RunnerUp/TCX verification +(`_runnerup_verification.py`) stays **ADB-only, unconditionally**. This +workstream must not touch `_runnerup_verification.py`'s trust model at all. +Per this repo's `CLAUDE.md`: *"Never log a RunnerUp workout from a user +claim. Always pull the actual TCX file from +`/sdcard/Documents/RunnerUp/` via ADB and verify it first."* GitHub-sync is +acceptable for `phone_verified` specifically because the workout **app** +(not the user) produces that JSON — it's a different trust boundary than a +GPS-verified run file. Moving *that* channel's transport does not weaken +the RunnerUp rule. + +## Two existing sync patterns in the sibling repos (pick one, don't invent a third) + +There are already two different GitHub-sync approaches running in +production in this user's other repos — read both before designing this +one: + +1. **diet-guard's Contents-API repo sync** + (`~/diet-guard/diet_guard/_sync.py` + `_sync_github.py` + + `_sync_merge.py`, ~120/190/80 lines respectively). `GitHubSyncClient` + (`_sync_github.py:38`) wraps the GitHub Contents API with + `get_file_text(path)`, `put_file_text(path, text, message=...)`, + `list_directory(path)` against a **private repo** + (`SYNC_REPO_OWNER="kuhyx"`, `SYNC_REPO_NAME="diet-guard-sync"` in + `_constants.py`), each device pushing to its own + `devices//food_log.json` path, and `_sync.py` orchestrating + pull-all → merge (`_sync_merge.merge_logs`) → re-sign → push. Auth is a + fine-grained PAT read from `~/.config/diet_guard/sync_token` (scoped to + just that one repo's contents). +2. **wake-alarm's Gist-based sync** + (`~/wake-alarm/shutdown-wrapper.sh`, phone app writes to a **private + Gist**, not a repo). Config at `~/.config/wake_alarm/gist_sync.json` + with `token`/`gist_id`. Much simpler (single JSON blob, no per-device + paths, no merge step) — matches wake-alarm's much simpler data shape (one + wake-time value, not a growing log). + +Screen-locker's `phone_verified` data is closer to diet-guard's shape (a +growing per-day log, multiple potential "devices" conceptually — though in +practice just one phone) than wake-alarm's (single current value). **Default +recommendation: model this on diet-guard's Contents-API pattern**, not +wake-alarm's Gist pattern — but confirm with the user before committing, +since it affects Workstream D's shared-library design too (a shared CRDT +transport library needs to pick one primitive, not support both). + +## Sequencing decision (resolved) + +**Decision (2026-07-04): wait for Workstream D.** Do not build this ahead of +D with throwaway code. This workstream stays blocked until +`workstream-d-shared-crdt-transport.md` is scoped, built, and screen-locker +adopts its shared CRDT transport library. When D lands, re-open this +document and build Workstream C on top of it rather than writing local +sync code first. + +## Concrete scope (resolved — apply once Workstream D unblocks this) + +1. New module `screen_locker/_sync_github.py` (or similar) — **but per + the sequencing decision above, this will be built on Workstream D's + shared Python CRDT transport library, not modeled directly on + diet-guard's pre-CRDT `GitHubSyncClient`.** Diet-guard's Contents-API + approach (private repo, per-device path under `devices//...`, + pull-all → merge → push) is still the reference *shape* to follow for + how screen-locker exposes/consumes the synced data — Workstream D should + implement that shape under the hood. +2. **Repo name (decided): `screen-locker-sync`** — new private repo, not a + reuse of an existing one, following the `diet-guard-sync` naming + convention. +3. `PhoneVerificationMixin._verify_phone_workout` (or a new sibling method) + tries GitHub-sync first, falls back to the existing + ADB-pull → HTTP-scan chain unchanged. +4. **Scope (decided): includes the phone-side push.** The `workout_app` + Flutter app (`stronglift_replacement/workout_app/`) needs a push + mechanism added in the same effort as the PC-side client — mirroring + diet-guard's `app/lib/services/sync_service.dart`, but built on + Workstream D's shared Dart CRDT transport library rather than + hand-rolled Dart sync code. Don't assume it's a small addition to the + existing HTTP-server code in the phone app; scope it explicitly when + this workstream is picked back up. +5. Auth: a scoped PAT, stored and read the same way diet-guard does + (`~/.config//sync_token`, never logged, never committed). + +## Open questions to resolve before coding + +- Contents-API repo sync or Gist sync (see above) — user decision. +- New private repo name, or reuse an existing sync repo? — user decision. +- Does the phone app push on every workout completion, or on a timer/on + next app open? Affects how "fresh" the PC-side pull needs to treat the + data (staleness handling — screen-locker's phone-verification already has + a `stale` status value in `_verify_phone_workout`'s return type; reuse + that concept here rather than inventing a new one). +- Should GitHub-sync failures (offline, bad PAT, rate limit) silently fall + back to ADB/HTTP, or surface as a distinct status the status view can + show? Recommend: silent fallback for the lock-decision path (must not + block the user from unlocking due to a sync hiccup), but the fallback + itself should be visible somewhere (log line at minimum) for + debuggability. + +## Verification plan + +1. Test the GitHub-sync client against a real (test) private repo/gist + before wiring it into the mixin — confirm push/pull round-trips work, + including auth-failure and rate-limit paths. +2. Confirm the existing ADB/HTTP fallback still works with the phone + unplugged from GitHub (i.e. sync client returns nothing) — this is the + regression risk: don't let a broken sync client silently break the + already-working fallback path. +3. Full existing `_phone_verification.py` test suite + (`test_phone_verification_part2/3/4.py`, `test_adb_and_phone.py`) stays + green; add new tests for the sync-first path with the fallback mocked. +4. Manually verify end-to-end with the real phone: workout completed on + phone → pushed to GitHub → PC pulls without ADB/same-LAN → screen + unlocks. + +## Critical files + +- `screen_locker/_phone_verification.py` (integration point) +- `screen_locker/_constants.py` (`WORKOUT_APP_JSON_REMOTES`, + `WORKOUT_HTTP_PORT` — new `SYNC_*` constants go here too) +- `stronglift_replacement/workout_app/lib/` (phone-side push, new work) +- Reference-only, do not modify: `~/diet-guard/diet_guard/_sync_github.py`, + `_sync.py`, `_sync_merge.py`; `~/wake-alarm/shutdown-wrapper.sh` +- **Do not touch**: `screen_locker/_runnerup_verification.py`, + `_runnerup_db.py` (hard boundary, see Context above) diff --git a/docs/todo/workstream-d-scoping-findings.md b/docs/todo/workstream-d-scoping-findings.md new file mode 100644 index 0000000..d6e84f4 --- /dev/null +++ b/docs/todo/workstream-d-scoping-findings.md @@ -0,0 +1,145 @@ +# Workstream D — scoping pass findings (2026-07-04) + +Answers the four scoping questions from `workstream-d-shared-crdt-transport.md`, +based on reading the actual code in all four repos (not the prior doc's +summaries) and researching current Python CRDT packages. No code was written +or modified in any repo for this pass, per that doc's explicit instruction. + +## Headline finding: "shared CRDT transport library" is two separable things + +The original doc bundles **transport** (how bytes move between devices) and +**merge** (how conflicting data reconciles) into one library. They turn out +to have very different shareability: + +- **Transport** (GitHub Contents API client: `get_file_text` / + `put_file_text` / `list_directory` against a private repo) is genuinely + shared already — it's independently duplicated almost line-for-line + between todo's `GitHubClient` and diet-guard's `GitHubSyncClient` + (`~/diet-guard/diet_guard/_sync_github.py`, 191 lines, `requests`-only). + Extracting this into a shared Python lib (and a shared Dart lib for + todo's/diet-guard's Dart-side client) is low-risk, small, and is the part + Workstream C already committed to (Contents-API pattern, not Gist — + see `workstream-c-github-sync-workout-data.md`). +- **Merge** is *not* uniformly shareable — see next section. Treating "pick + one CRDT scheme for all four apps" as a single decision undersells that + todo and the other three apps are solving different problems. + +Recommendation: split the library (or at least the decision) along this +seam. Extract shared transport now; treat "one unified merge scheme" as a +separate, larger, and more questionable decision (see "Sub-decisions" below). + +## 1. Python CRDT landscape check + +Checked PyPI for currently-maintained options (search run 2026-07-04): + +| Package | Status | Fit | +|---|---|---| +| `pycrdt` | Actively maintained (0.14.1, June 2026), owned by Project Jupyter | Bindings for Yrs (Rust Yjs port). Built for rich collaborative documents (maps/arrays/text with binary Yjs encoding) — the same engine behind JupyterLab real-time collaboration. Wrong shape for this use case: output isn't git-diffable JSON, has no SQLite/flat-file integration, and models a fundamentally richer editing problem (concurrent character-level text edits) than "append a log entry, sometimes delete one." Adopting it would mean encoding every log entry into a Yjs doc and pushing an opaque binary blob to GitHub instead of the current human-readable JSON — a downgrade in debuggability for no benefit these apps need. | +| `crdts`, `python3-crdt`, `crdt-py` | No PyPI release in 12+ months each — effectively unmaintained | Implement classic primitives (G-Set, OR-Set, LWW-Element-Set, PN-Counter) which are closer in shape to what's needed, but taking on an unmaintained external dependency for a handful of well-understood, stable algorithms is a worse trade than owning the ~80 lines directly. | +| `cr-sqlite` | Actively maintained, but is a native loadable SQLite *extension*, not a Python package — usable from Python's `sqlite3` module once compiled/loaded | Would require migrating diet-guard/screen-locker/wake-alarm's storage from flat JSON files to SQLite first. None of them store data in SQLite today. That's a materially bigger, unrelated migration bundled into this workstream — out of scope unless the user wants to widen it. | + +**Conclusion: no third-party package fits "CRDT over a flat, git-diffable +JSON log" well.** The pragmatic answer isn't "adopt X" or "hand-roll a CRDT +from scratch" (the doc's original framing) — it's **formalize what +diet-guard already built**. `diet_guard/_sync_merge.py`'s entry-id-keyed, +tombstone-wins merge (mirrored test-for-test in +`app/lib/services/sync_merge.dart`) already has the properties a CRDT needs +here — commutative and idempotent per its own docstring and the mirrored +Python/Dart test suites — *given one constraint*: entry bodies are never +mutated after creation, only `deleted`/`hmac` change. That's a **remove-wins +register per entry**, not a general add-wins OR-Set, and it works precisely +*because* diet-guard's and screen-locker's data is an append-only log of +otherwise-immutable entries. Extracting this ~80-line scheme into the shared +Python lib (and its Dart mirror into the shared Dart lib) is a small, +low-risk task with a working reference implementation and existing tests to +port, not new CRDT design. + +## 2. Transport primitive decision + +**Contents-API repo pattern**, not Gist. This was already decided for +Workstream C and this pass confirms it's the right call for the shared +library too: + +- todo and diet-guard already both use a Contents-API-shaped client + independently (private repo, `listDirectory`/`getFileText`/`putFileText`) + — it's the de facto standard across this user's repos already, not a new + choice. +- wake-alarm is the only Gist user, and (see sub-decision 2 below) its data + shape doesn't need what the shared library is for in the first place. + +## 3. Per-app migration cost (revised — the original doc's "todo = smallest +lift" assumption does not hold) + +Checked `~/todo/lib/data/note_repository.dart`: notes are **mutated in +place** — `upsert()` does `INSERT ... ON CONFLICT DO UPDATE SET text = ?, +priority = ?, status = ?, updated_at = ?`, relying on `sqlite_crdt`'s +per-column Hybrid-Logical-Clock last-writer-wins to reconcile concurrent +edits to the *same* note's *same* fields. This is a genuinely different CRDT +problem from diet-guard's/screen-locker's append-only, immutable-once-written +log entries. A shared scheme built around diet-guard's tombstone approach +(section 1) does not cover todo's need without significant extension +(effectively re-deriving field-level LWW/HLC in the shared lib) — and +`sqlite_crdt` already solves todo's exact problem well today. + +| App | Data shape | Current state | Migration cost if unified merge scheme required | Migration cost if shared transport only | +|---|---|---|---|---| +| todo | Mutable records, per-field edits | `sqlite_crdt` (HLC-based, mature, in production) | **L** — would need the shared lib to grow field-level LWW to match what it already has, i.e. reimplement `sqlite_crdt`'s core in the shared lib for no behavior gain | **S** — keep `sqlite_crdt` for merge, optionally swap its transport for the shared client | +| diet-guard | Append-only log, tombstoned deletes, immutable bodies | Hand-rolled tombstone merge (Python + Dart, mirrored) | **S** — this *is* the reference implementation to extract | **S** — same, transport already Contents-API-shaped | +| wake-alarm | Single current scalar (next wake time), single writer, no history | No merge logic at all (Gist overwrite) | **M–L** for no behavioral gain — see sub-decision 2 | **S** if just swapping Gist → Contents-API for transport consistency; **none needed** if left as-is | +| screen-locker | Append-only log (workout entries), net-new sync | Nothing exists yet | **S** — greenfield, adopts whatever the shared lib provides directly | **S** — same | + +## 4. Sequencing relative to Workstream C + +The prior "C waits for D" decision was made when D was "not started, not +scoped" (2026-07-04, same day). This scoping pass changes what's actually +being waited on: the *transport* piece (what C needs first — Contents-API +client) is small, low-risk, and effectively already designed by example in +diet-guard's `_sync_github.py`. The *merge-unification* question (does one +scheme cover all four apps, per sub-decision 1) is the open, larger piece — +and screen-locker's own data (an append-only workout log, same shape as +diet-guard's) doesn't actually need that question resolved first, since the +diet-guard-style tombstone scheme already fits it directly. + +**Flagging, not deciding:** it may be possible to unblock Workstream C on +just the shared transport client + the diet-guard-style merge scheme, +without waiting for a resolution on whether/how todo's different merge +problem folds into the "shared" library. That's a sequencing option to +confirm with the user, not something this pass resolves unilaterally. + +## Sub-decisions — resolved (2026-07-04, user call, overriding this doc's recommendations) + +Both sub-decisions below were put to the user with an explicit recommendation +each way this pass leaned against; the user considered the trade-off and +decided the other way on both. Recorded here as settled, not open: + +1. **One unified merge scheme, covering all four apps including todo.** + This means the shared library's merge scheme must be extended beyond the + diet-guard-style tombstone-log scheme to also handle todo's mutable, + per-field-edited notes — i.e. it needs to grow something equivalent to + `sqlite_crdt`'s per-column HLC last-writer-wins, in Python and Dart, not + just the simpler append/tombstone case. This is materially more design + and implementation work than the transport-only extraction (was flagged + as **L** cost in the table above) — budget for it accordingly when this + moves to an implementation plan. +2. **wake-alarm converts to CRDT merge too**, despite its data (single + current wake-time value, one writer, no history) having no actual + concurrent-edit conflict for a CRDT to resolve. This is consistency-driven + rather than need-driven — noted so a future implementer isn't confused + about why a single-scalar value is running through CRDT merge logic; it's + an intentional choice, not an oversight. + +Net effect on scope: this makes Workstream D closer to its original framing +(one CRDT scheme, all four apps) than the leaner split this pass initially +recommended. The Python-CRDT-landscape conclusion from section 1 still holds +regardless — no third-party package fits, so the unified scheme is still +something to build by generalizing diet-guard's approach and folding in +per-field LWW for todo/wake-alarm's single-value case, not something to pull +off the shelf. + +## Critical files referenced (read-only during this pass) + +- `~/todo/lib/sync/sync_service.dart`, `~/todo/lib/data/note_repository.dart` +- `~/diet-guard/diet_guard/_sync.py`, `_sync_github.py`, `_sync_merge.py` +- `~/diet-guard/app/lib/services/sync_merge.dart` +- `~/wake-alarm/shutdown-wrapper.sh` +- `docs/todo/workstream-c-github-sync-workout-data.md` (this repo) diff --git a/docs/todo/workstream-d-shared-crdt-transport.md b/docs/todo/workstream-d-shared-crdt-transport.md new file mode 100644 index 0000000..5ca4000 --- /dev/null +++ b/docs/todo/workstream-d-shared-crdt-transport.md @@ -0,0 +1,137 @@ +# Workstream D — Shared CRDT transport library across 4 repos + +## Status + +Scoping pass complete — see `workstream-d-scoping-findings.md` (2026-07-04) +for the answers to the four questions below and two sub-decisions that fell +out of it. Still not implemented; those sub-decisions need a user call +before any code is written. Split out from the original status-view plan +(`~/.claude/plans/screen-locker-status-streamed-feigenbaum.md`, Workstream A). +The plan is explicit: **"Scope and spec this as its own dedicated task +before writing any code — do not start it opportunistically inside +Workstream A/B/C work."** This document is orientation for that future +scoping pass, not a spec to start coding from. + +## The decision this workstream implements + +Per explicit prior user decision: build a shared **Python** CRDT transport +library (used by screen-locker, diet-guard, wake-alarm PC-sides) and a +shared **Dart** CRDT transport library (used by todo, diet-guard app, +wake-alarm `phone_app`, screen-locker's `workout_app`), with **all four +apps adopting CRDT-based merge** — not just todo's existing use of it. This +was flagged during the original planning conversation as more complexity +than diet-guard/wake-alarm/screen-locker's simpler log data strictly needs, +and the user chose it anyway as a deliberate future-proofing call. That +tradeoff decision stands; this document doesn't re-litigate it, only +records what's actually true about the current state so a future scoping +pass starts from facts, not assumptions. + +## What's actually true today (verified by reading the code, not assumed) + +**todo's "CRDT approach" is the `sqlite_crdt` package**, not hand-rolled +logic: `todo/lib/sync/sync_service.dart` imports +`package:sqlite_crdt/sqlite_crdt.dart` and syncs by "pulling every other +device's full CRDT changeset" (per that file's own doc comment) — merge +conflict resolution is handled entirely inside that library, not in +app code. This matters: Workstream D's Dart-side library is not "extract +what todo already built," it's "wrap/standardize how todo already uses a +third-party package," which is a smaller and clearer task than a +from-scratch CRDT implementation. + +**diet-guard and wake-alarm do NOT use CRDTs today.** Their existing +"merge" mechanisms are much simpler: +- diet-guard: `_sync_merge.merge_logs` (`~/diet-guard/diet_guard/_sync_merge.py`, + 81 lines) is a tombstone-aware, entry-key-based merge — closer to + last-write-wins per entry than a general CRDT. Transport is + `_sync_github.GitHubSyncClient` against a private repo's Contents API + (see Workstream C's doc for the concrete interface). +- wake-alarm: `shutdown-wrapper.sh` reads a **private GitHub Gist** + directly in bash — no merge logic at all, because it's a single current + value (next wake time), not a growing log. This is the piece the + original plan flags as needing a rewrite ("wake-alarm's current + bash-based PC-side gist reader... into something that can participate in + a CRDT merge") — but note its data model (one current value, no history) + may not even need CRDT semantics; forcing it into the same abstraction + as diet-guard's growing log is itself a design choice to confirm, not a + given. + +**No existing Python CRDT-backed storage layer exists in any of these +repos.** Unlike the Dart side (where `sqlite_crdt` is a mature, adopted +package), the Python PC-side transport library has no equivalent starting +point. This is the biggest unknown in scoping this workstream: does a +suitable Python CRDT library exist and fit sqlite/JSON-log use cases, or +does "shared Python CRDT transport library" mean writing CRDT merge logic +from scratch in Python? That question needs an answer before any +time/complexity estimate for this workstream means anything. + +## Why this is a separate, large task (not a natural extension of A/B/C) + +- Spans 4 repositories (todo, diet-guard, wake-alarm, screen-locker) and 2 + languages (Dart, Python). +- Touches production sync code in apps that are already shipping and + working (diet-guard's Contents-API sync, wake-alarm's Gist reader) — + regressions here have real user-facing consequences (diet-guard's own + memory notes record a prior 3-day silent outage from a similar + deployment-path mismatch — see + `feedback-verify-real-deployment-path.md` in this user's memory). +- Requires picking one canonical transport primitive (Gist vs. Contents-API + repo, per Workstream C's open question) that all four apps then share — + a decision this document deliberately does not make, since it depends on + Workstream C's resolution. +- The Python-side CRDT gap (previous section) may turn "build a shared + library" into "evaluate/adopt a third-party Python CRDT library" or + "design a minimal CRDT scheme from scratch" — materially different + scopes that need to be distinguished before estimating. + +## Scoping pass — what actually needs to happen before coding starts + +This is not an implementation plan. Before any code is written for this +workstream, produce (as a separate, dedicated planning task): + +1. **Python CRDT landscape check**: does a maintained Python package exist + that plays well with SQLite or flat JSON logs (the data shapes + diet-guard/wake-alarm/screen-locker actually use today)? If none fits, + decide: adopt something not-quite-right, or hand-roll a minimal + CRDT (e.g. a simple LWW-register or OR-set scheme, since the data here + is genuinely simple — daily log entries, not concurrent rich-text + editing). +2. **Transport primitive decision**: Gist vs. Contents-API repo (see + Workstream C) — must be settled first since the shared library wraps + whichever one is chosen, for all 4 apps, not per-app. +3. **Per-app migration cost, assessed individually**: + - todo: likely smallest — already on `sqlite_crdt`, mainly a question of + whether it also needs to move onto the shared library's transport + wrapper, or keeps its current transport untouched. + - diet-guard: rewrite `_sync_merge.py`'s tombstone-LWW merge onto a real + CRDT scheme, both Python (`_sync.py`) and Dart + (`app/lib/services/sync_merge.dart`) sides. + - wake-alarm: full rewrite of `shutdown-wrapper.sh`'s bash Gist reader + into something CRDT-capable — the single biggest unknown, since it's + currently the simplest and most different data model of the four + (single value, no log, no merge today at all). + - screen-locker: net-new — no existing sync of any kind yet + (Workstream C is what introduces sync here at all; if C is built with + "local, throwaway sync code" per its own doc, D's arrival means + replacing that code, not extending it). +4. **Sequencing relative to Workstream C**: confirm whether C should be (a) + built on local throwaway sync code now and migrated onto D's library + later, or (b) blocked until D's library exists. This is Workstream C's + open question too — resolve it once, here or there, not independently + in both places. + +## Explicitly out of scope for the scoping pass itself + +Do not write any CRDT library code, do not touch any of the 4 repos' +existing sync mechanisms, and do not commit to the Python-CRDT-library +question's answer without actually researching current (as of when this +task is picked up) Python package options — the answer may be stale by the +time this is scoped for real. + +## Critical files (reference only — read, do not modify, during scoping) + +- `~/todo/lib/sync/sync_service.dart`, `sync_settings.dart` (CRDT-via-package pattern) +- `~/diet-guard/diet_guard/_sync.py`, `_sync_github.py`, `_sync_merge.py` +- `~/diet-guard/app/lib/services/sync_service.dart`, `sync_merge.dart` +- `~/wake-alarm/shutdown-wrapper.sh` +- `docs/todo/workstream-c-github-sync-workout-data.md` (this repo — resolve + its open questions jointly with this one, not independently) diff --git a/pyproject.toml b/pyproject.toml index b536644..a482550 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,9 @@ dependencies = [ "gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0", ] +[project.scripts] +screen-locker-status = "screen_locker.status_view:main" + [tool.setuptools.packages.find] # This repo also holds stronglift_replacement/ (an unrelated Flutter app, not # a Python package); without this, setuptools' automatic flat-layout @@ -35,15 +38,27 @@ unfixable = [] [tool.ruff.lint.per-file-ignores] "**/tests/**/*.py" = [ "ANN", "ARG", "D", "DTZ011", "E501", "FBT", - "PLC0415", "PLR2004", "PTH", "RUF003", "S101", "SLF001", + "PLC0415", "PLR0913", "PLR2004", "PTH", "RUF003", "S101", "SLF001", ] "**/test_*.py" = [ "ANN", "ARG", "D", "DTZ011", "E501", "FBT", - "PLC0415", "PLR2004", "PTH", "RUF003", "S101", "SLF001", + "PLC0415", "PLR0913", "PLR2004", "PTH", "RUF003", "S101", "SLF001", ] "screen_locker/_runnerup_verification.py" = ["S314"] "screen_locker/_shutdown_base.py" = ["SLF001"] "screen_locker/_status.py" = ["PLR0915", "SLF001", "T201"] +"screen_locker/status_view.py" = ["PLC0415", "SLF001", "T201"] +# explain_lock_decision takes one arg per real file path + sick history, 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"] +# gather_status takes one arg per real file path, all independently +# overridable for tests — same rationale as _compliance_state.py above. +"screen_locker/_status_data.py" = ["PLR0913"] +# validate_manual_workout returns early per failed evidence-form check (same +# validation-chain shape as _compliance_state.py's explain_lock_decision) — +# deliberate design, not something to collapse into a config object. +"screen_locker/_manual_workout.py" = ["C901", "PLR0911"] "scripts/check_file_length.py" = ["PTH123"] [tool.ruff.lint.pydocstyle] diff --git a/screen_locker/_compliance_state.py b/screen_locker/_compliance_state.py new file mode 100644 index 0000000..715c569 --- /dev/null +++ b/screen_locker/_compliance_state.py @@ -0,0 +1,383 @@ +"""Pure, read-only predicates mirroring the lock-decision chain. + +Single source of truth for the leaf checks walked by +``screen_lock.ScreenLocker._check_non_verify_exits`` and +``_auto_upgrade.AutoUpgradeMixin._check_today_state_exits``. The existing +mixins delegate their read-only checks to the functions here; this module +never touches ADB, sudo, subprocess, or the network, and never writes to +disk. + +``explain_lock_decision`` re-derives the same ordered chain for the status +view. One branch is a deliberate simplification: when an early-bird pending +marker has expired, the real code attempts a live phone/RunnerUp +auto-upgrade whose outcome can't be known without ADB. Rather than guess, +this module always continues the trace as if that attempt had *not yet +resolved* (mirroring the real code's "auto-upgrade failed" fallthrough) and +surfaces the pending opportunity separately via ``AutoUpgradeOpportunity``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import json +import logging +from typing import TYPE_CHECKING + +from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac + +from screen_locker._constants import ( + EARLY_BIRD_END_HOUR, + EARLY_BIRD_END_MINUTE, + EARLY_BIRD_START_HOUR, +) +from screen_locker._sick_tracker import is_sick_day as _is_sick_day + +if TYPE_CHECKING: + from pathlib import Path + + from screen_locker._sick_tracker import SickHistory + +_logger = logging.getLogger(__name__) + + +def _today_str() -> str: + """Return today's date as ``YYYY-MM-DD`` in UTC.""" + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + + +@dataclass(frozen=True) +class PredicateResult: + """One evaluated step in a :class:`LockExplanation` trace.""" + + name: str + fired: bool + reason: str + + +@dataclass(frozen=True) +class AutoUpgradeOpportunity: + """Describes whether the real locker would attempt a live auto-upgrade.""" + + would_attempt: bool + via: str # "early_bird_expired" | "sick_day" | "none" + reason: str + + +@dataclass(frozen=True) +class LockExplanation: + """Read-only reconstruction of what the lock-decision chain would do.""" + + fired: bool + stage: str + reason: str + trace: tuple[PredicateResult, ...] + auto_upgrade: AutoUpgradeOpportunity + heat_skip_evaluated: bool + + +_NO_UPGRADE = AutoUpgradeOpportunity( + would_attempt=False, + via="none", + reason="No pending auto-upgrade opportunity.", +) + + +def is_scheduled_skip_today( + scheduled_skips_file: Path, *, today: str | None = None +) -> bool: + """Return True if *today* is listed in *scheduled_skips_file*.""" + if not scheduled_skips_file.exists(): + return False + try: + with scheduled_skips_file.open() as f: + skips = json.load(f) + except (OSError, json.JSONDecodeError): + return False + return (today or _today_str()) in skips + + +def has_logged_today(log_file: Path, *, today: str | None = None) -> bool: + """Return True if a validly-signed workout is logged for *today*.""" + if not log_file.exists(): + return False + try: + with log_file.open() as f: + logs = json.load(f) + except (OSError, json.JSONDecodeError): + return False + entry = logs.get(today or _today_str()) + if entry is None: + return False + if verify_entry_hmac(entry): + return True + if compute_entry_hmac({"_probe": True}) is None and "hmac" not in entry: + _logger.info("HMAC key unavailable — accepting unsigned entry") + return True + _logger.warning("HMAC verification failed for today's log entry") + return False + + +def is_early_bird_pending(pending_file: Path, *, today: str | None = None) -> bool: + """Return True if today has an unresolved early-bird pending marker.""" + if not pending_file.exists(): + return False + try: + with pending_file.open() as f: + state = json.load(f) + except (OSError, json.JSONDecodeError): + return False + if not isinstance(state, dict) or state.get("date") != (today or _today_str()): + return False + if verify_entry_hmac(state): + return True + if compute_entry_hmac({"_probe": True}) is None and "hmac" not in state: + _logger.info("HMAC key unavailable — accepting unsigned pending marker") + return True + _logger.warning("HMAC verification failed for early-bird pending marker") + return False + + +def is_sick_day_today(history: SickHistory, *, today: str | None = None) -> bool: + """Return True if today is recorded as a sick day. + + Thin wrapper around ``_sick_tracker.is_sick_day`` kept here so callers of + the status layer have one predicate module to import from. + """ + return _is_sick_day(history, today=today) + + +def _early_bird_window_open(*, extended: bool, local_minutes: int) -> bool: + """Deliberate, independent reimplementation of ``_is_early_bird_time``. + + Not shared with ``EarlyBirdMixin._is_early_bird_time`` because too many + existing tests are pinned to its ``_get_local_time_minutes`` patch point. + Kept here as a small, separate, pure re-implementation for the status + layer only — see the module docstring in ``_early_bird.py``. + """ + start = EARLY_BIRD_START_HOUR * 60 + end = 9 * 60 if extended else EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE + return start <= local_minutes < end + + +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. + + Read-only stand-in for ``AutoUpgradeMixin._check_today_state_exits``'s + branching — never calls phone/RunnerUp verification and never writes to + ``workout_log.json``. + """ + if early_bird_pending and not early_bird_window_open: + return AutoUpgradeOpportunity( + would_attempt=True, + via="early_bird_expired", + reason=( + "Early-bird window closed with no workout logged — the next " + "lock run will try phone/RunnerUp verification before " + "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 + + +def _stage( + *, + fired: bool, + stage: str, + reason: str, + trace: list[PredicateResult], + auto_upgrade: AutoUpgradeOpportunity, +) -> LockExplanation: + """Build a terminal :class:`LockExplanation` from the trace so far.""" + return LockExplanation( + fired=fired, + stage=stage, + reason=reason, + trace=tuple(trace), + auto_upgrade=auto_upgrade, + heat_skip_evaluated=False, + ) + + +def explain_lock_decision( + *, + log_file: Path, + scheduled_skips_file: Path, + early_bird_pending_file: Path, + sick_history: SickHistory, + extended_early_bird: bool, + weekly_minimum_met: bool, + relaxed_day: bool, + wake_skip: bool = False, + now: datetime | None = None, +) -> LockExplanation: + """Re-derive the lock-decision chain without ADB, sudo, or writes. + + Mirrors ``_check_non_verify_exits`` / ``_check_today_state_exits`` in + order. Heat-skip is never evaluated (it needs a live ``wttr.in`` call) — + reported via ``heat_skip_evaluated=False``, not guessed. + """ + instant = now if now is not None else datetime.now(tz=timezone.utc) + today_str = instant.astimezone(timezone.utc).strftime("%Y-%m-%d") + local_dt = instant.astimezone() + local_minutes = local_dt.hour * 60 + local_dt.minute + + scheduled = is_scheduled_skip_today(scheduled_skips_file, today=today_str) + pending = is_early_bird_pending(early_bird_pending_file, today=today_str) + window_open = _early_bird_window_open( + extended=extended_early_bird, local_minutes=local_minutes + ) + 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) + logged = has_logged_today(log_file, today=today_str) + + auto_upgrade = describe_auto_upgrade_opportunity( + early_bird_pending=pending, + early_bird_window_open=window_open, + is_sick_day=sick_today, + ) + + trace: list[PredicateResult] = [] + + def _check( + name: str, + *, + fired: bool, + reason_true: str, + reason_false: str, + terminal_reason: str | None = None, + ) -> LockExplanation | None: + """Record one trace step; return an explanation if it terminates the chain.""" + reason = reason_true if fired else reason_false + trace.append(PredicateResult(name, fired, reason)) + if fired and terminal_reason is not None: + return _stage( + fired=False, + stage=name, + reason=terminal_reason, + trace=trace, + auto_upgrade=auto_upgrade, + ) + return None + + result = _check( + "scheduled_skip", + fired=scheduled, + reason_true="Today is in scheduled_skips.json (unconditional manual skip).", + reason_false="Today is not a manually scheduled skip day.", + terminal_reason="Manually scheduled skip day — no lock, no workout required.", + ) + if result is not None: + return result + + # Recorded but never terminal on its own: an expired pending marker still + # falls through toward a full lock, with the upgrade opportunity surfaced + # separately via `auto_upgrade` (see module docstring). + _check( + "early_bird_pending_expired", + fired=expired, + reason_true="Logged in during the early-bird window and it has since closed " + "with no workout confirmed yet.", + reason_false="No expired early-bird pending marker.", + ) + + result = _check( + "early_bird_window_open", + fired=early_bird_open_and_pending, + reason_true="Logged in during the early-bird window earlier today; still " + "waiting to see a workout before the window closes.", + reason_false="Not pending inside an open early-bird window.", + terminal_reason=( + "Early-bird window still open — lock skipped while waiting for a workout." + ), + ) + if result is not None: + return result + + 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.", + ) + if result is not None: + return result + + result = _check( + "already_logged", + fired=logged, + reason_true="A validly-signed workout is already logged for today.", + reason_false="No workout logged for today yet.", + terminal_reason="Workout already logged today — lock skipped.", + ) + if result is not None: + return result + + result = _check( + "wake_alarm_skip", + fired=wake_skip, + reason_true="Wake-alarm earned a workout skip for today.", + reason_false="No wake-alarm workout skip earned today.", + terminal_reason="Wake-alarm earned a workout skip — lock skipped.", + ) + if result is not None: + return result + + result = _check( + "early_bird_time_fresh", + fired=window_open, + reason_true="Currently inside the early-bird window (05:00-08:30/09:00).", + reason_false="Not currently inside the early-bird window.", + terminal_reason="Inside the early-bird window — lock skipped, pending marker " + "would be saved for the 08:30/09:00 re-check.", + ) + if result is not None: + return result + + result = _check( + "relaxed_day", + fired=relaxed_day, + reason_true="Today is a relaxed day (Tue/Wed/Thu).", + reason_false="Today is not a relaxed day.", + terminal_reason="Relaxed day (Tue/Wed/Thu) — workout optional, lock skipped.", + ) + if result is not None: + return result + + result = _check( + "weekly_minimum_met", + fired=weekly_minimum_met, + reason_true="Weekly workout minimum already reached.", + reason_false="Weekly workout minimum not yet reached.", + terminal_reason="Weekly workout minimum already met — lock skipped.", + ) + if result is not None: + return result + + return _stage( + fired=True, + stage="full_lock_pending_heat_check", + reason=( + "No skip condition applies. The real locker still checks live " + "Warsaw temperature before showing the full lock — not " + "evaluated here." + ), + trace=trace, + auto_upgrade=auto_upgrade, + ) diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index 204829e..c4c2d1b 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -31,6 +31,19 @@ SICK_COMMITMENT_FORCED_READ_SECONDS = 5 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 +# window is exhausted the "Log Manual Workout" option disappears entirely. +MANUAL_WORKOUT_BUDGET_PER_7_DAYS = 2 +MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 5 +# Minimum start-to-end duration for a manual entry to be accepted. +MANUAL_WORKOUT_MIN_DURATION_MINUTES = 20 +# Minimum chars for the "what was done" activity-details field. +MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS = 40 +# Minimum chars for each reflection field (what went well / to improve / feeling). +MANUAL_WORKOUT_REFLECTION_MIN_CHARS = 20 +MANUAL_WORKOUT_RPE_MIN = 1 +MANUAL_WORKOUT_RPE_MAX = 10 ADB_TIMEOUT = 15 # Workout app JSON candidate paths on the phone, in the order the app prefers # when writing (see sync_service.dart). The app writes to the primary /sdcard/ diff --git a/screen_locker/_early_bird.py b/screen_locker/_early_bird.py index a6c6118..8d4bbe3 100644 --- a/screen_locker/_early_bird.py +++ b/screen_locker/_early_bird.py @@ -13,8 +13,9 @@ from datetime import datetime, timezone import json import logging -from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac +from gatelock.log_integrity import compute_entry_hmac +from screen_locker._compliance_state import is_early_bird_pending from screen_locker._constants import ( EARLY_BIRD_END_HOUR, EARLY_BIRD_END_MINUTE, @@ -57,22 +58,7 @@ class EarlyBirdMixin: def _is_early_bird_pending(self) -> bool: """Check if today has an unresolved early-bird pending marker.""" - if not EARLY_BIRD_PENDING_FILE.exists(): - return False - try: - with EARLY_BIRD_PENDING_FILE.open() as f: - state = json.load(f) - except (OSError, json.JSONDecodeError): - return False - if not isinstance(state, dict) or state.get("date") != _today_str(): - return False - if verify_entry_hmac(state): - return True - if compute_entry_hmac({"_probe": True}) is None and "hmac" not in state: - _logger.info("HMAC key unavailable — accepting unsigned pending marker") - return True - _logger.warning("HMAC verification failed for early-bird pending marker") - return False + return is_early_bird_pending(EARLY_BIRD_PENDING_FILE) def _save_early_bird_pending(self) -> None: """Save today's early-bird pending marker (self-expires tomorrow).""" diff --git a/screen_locker/_extra_benefits.py b/screen_locker/_extra_benefits.py index 8a071c7..d47bf58 100644 --- a/screen_locker/_extra_benefits.py +++ b/screen_locker/_extra_benefits.py @@ -55,6 +55,23 @@ def _current_iso_week(now: datetime) -> str: return f"{year}-W{week:02d}" +def preview_bonus_if_week_ended_now( + current_week_count: int, current_streak: int +) -> tuple[int, int]: + """(would_be_new_streak, would_be_bonus_hours) if the ISO week ended now. + + Pure extraction of ``process_week_transition``'s threshold math — no file + I/O, safe to call from a read-only status view for a "next week preview." + """ + if current_week_count < _BONUS_THRESHOLD: + return 0, 0 + streak = current_streak + 1 + bonus_hours = current_week_count - 4 + if streak % _MILESTONE_INTERVAL == 0: + bonus_hours += 1 + return streak, bonus_hours + + def process_week_transition(log_file: Path, state_file: Path) -> list[str]: """Process last week's results if we've entered a new ISO week. @@ -100,8 +117,7 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]: if prev_week_count >= _BONUS_THRESHOLD: extra = prev_week_count - 4 - streak += 1 - bonus_hours = extra + streak, bonus_hours = preview_bonus_if_week_ended_now(prev_week_count, streak) if current_week_str not in eb_weeks: eb_weeks.append(current_week_str) rewards.append( @@ -109,7 +125,6 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]: f"+{extra}h shutdown bonus this week, early-bird extended to 09:00" ) if streak % _MILESTONE_INTERVAL == 0: - bonus_hours += 1 rewards.append(f"{streak}-week streak milestone! +1h extra shutdown bonus") weekly_bonus_hours[current_week_str] = bonus_hours else: @@ -146,9 +161,9 @@ def weekly_shutdown_bonus_hours( return int(bonus_hours.get(current_week_str, 0)) -def has_extended_early_bird(state_file: Path) -> bool: +def has_extended_early_bird(state_file: Path, *, today: datetime | None = None) -> bool: """Return True if the current ISO week has an extended early-bird window (09:00).""" - now = datetime.now(tz=timezone.utc).astimezone() + now = today if today is not None else datetime.now(tz=timezone.utc).astimezone() current_week_str = _current_iso_week(now) eb_weeks: list[str] = _load_state(state_file).get( "extended_early_bird_iso_weeks", [] diff --git a/screen_locker/_log_mixin.py b/screen_locker/_log_mixin.py index 08bf50c..91c93d4 100644 --- a/screen_locker/_log_mixin.py +++ b/screen_locker/_log_mixin.py @@ -5,60 +5,49 @@ from __future__ import annotations from datetime import datetime, timezone import json import logging +from typing import TYPE_CHECKING -from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac +from gatelock.log_integrity import compute_entry_hmac +from screen_locker import _compliance_state from screen_locker._constants import SCHEDULED_SKIPS_FILE +if TYPE_CHECKING: + from collections.abc import Mapping + from pathlib import Path + _logger = logging.getLogger(__name__) class LogMixin: - """Handles reading and writing workout_log.json for the ScreenLocker.""" + """Handles reading and writing workout_log.json for the ScreenLocker. + + ``log_file``/``workout_data`` are declared here (not assigned) so mypy + knows their types on any composing class without needing + ``type: ignore[attr-defined]`` on every access — the real values are set + by ``ScreenLocker.__init__``. + """ + + log_file: Path + workout_data: Mapping[str, object] def has_logged_today(self) -> bool: """Check if workout has been logged today with valid HMAC.""" - if not self.log_file.exists(): # type: ignore[attr-defined] - return False - try: - with self.log_file.open() as f: # type: ignore[attr-defined] - logs = json.load(f) - except (OSError, json.JSONDecodeError): - return False - else: - today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - entry = logs.get(today) - if entry is None: - return False - if verify_entry_hmac(entry): - return True - if compute_entry_hmac({"_probe": True}) is None and "hmac" not in entry: - _logger.info("HMAC key unavailable — accepting unsigned entry") - return True - _logger.warning("HMAC verification failed for today's log entry") - return False + return _compliance_state.has_logged_today(self.log_file) def _load_existing_logs(self) -> dict: """Load existing workout logs from file.""" - if not self.log_file.exists(): # type: ignore[attr-defined] + if not self.log_file.exists(): return {} try: - with self.log_file.open() as f: # type: ignore[attr-defined] + with self.log_file.open() as f: return json.load(f) except (OSError, json.JSONDecodeError): return {} def _is_scheduled_skip_today(self) -> bool: """Return True if today's date is listed in the scheduled skips file.""" - if not SCHEDULED_SKIPS_FILE.exists(): - return False - try: - with SCHEDULED_SKIPS_FILE.open() as f: - skips = json.load(f) - except (OSError, json.JSONDecodeError): - return False - today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - return today in skips + return _compliance_state.is_scheduled_skip_today(SCHEDULED_SKIPS_FILE) def save_workout_log(self) -> None: """Save workout data to log file with HMAC signature.""" @@ -66,7 +55,7 @@ class LogMixin: today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") entry: dict[str, object] = { "timestamp": datetime.now(tz=timezone.utc).isoformat(), - "workout_data": self.workout_data, # type: ignore[attr-defined] + "workout_data": self.workout_data, } signature = compute_entry_hmac(entry) if signature is not None: @@ -75,7 +64,7 @@ class LogMixin: _logger.warning("HMAC key unavailable — saving unsigned entry") logs[today] = entry try: - with self.log_file.open("w") as f: # type: ignore[attr-defined] + with self.log_file.open("w") as f: json.dump(logs, f, indent=2) except OSError as e: _logger.warning("Could not save workout log: %s", e) diff --git a/screen_locker/_manual_workout.py b/screen_locker/_manual_workout.py new file mode 100644 index 0000000..4b250c2 --- /dev/null +++ b/screen_locker/_manual_workout.py @@ -0,0 +1,312 @@ +"""Manual (unverified) workout rate-limiting, validation, and entry-building. + +Pure logic — no Tk imports. Unlike sick days, manual-workout entries live +directly in ``workout_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 +``COUNTED_WORKOUT_TYPES``. This module only adds the rate-budget check (by +scanning ``workout_log.json`` itself) and the evidence-form validation. + +Each sport has its own structured fields (e.g. table tennis tracks +matches/sets won-lost and racket/balls, rather than one free-text blob) — +see :class:`ManualWorkoutDraft` and :data:`SPORT_CHOICES`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import json +import logging +from typing import TYPE_CHECKING, Any + +from screen_locker._constants import ( + MANUAL_WORKOUT_BUDGET_PER_7_DAYS, + MANUAL_WORKOUT_BUDGET_PER_30_DAYS, + MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS, + MANUAL_WORKOUT_MIN_DURATION_MINUTES, + MANUAL_WORKOUT_REFLECTION_MIN_CHARS, + MANUAL_WORKOUT_RPE_MAX, + MANUAL_WORKOUT_RPE_MIN, +) + +if TYPE_CHECKING: + from pathlib import Path + +_logger = logging.getLogger(__name__) + +MANUAL_WORKOUT_TYPE = "manual_workout" + +SPORT_TABLE_TENNIS = "table_tennis" +SPORT_OTHER = "other" +SPORT_CHOICES: tuple[str, ...] = (SPORT_TABLE_TENNIS, SPORT_OTHER) +SPORT_LABELS: dict[str, str] = { + SPORT_TABLE_TENNIS: "Table tennis", + SPORT_OTHER: "Other", +} + + +def _today_iso() -> str: + """Return today's date as ``YYYY-MM-DD`` (UTC).""" + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + + +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=timezone.utc) + except ValueError: + return None + + +def _load_logs(log_file: Path) -> dict[str, Any]: + """Read ``workout_log.json``. Missing or unreadable file yields ``{}``.""" + if not log_file.exists(): + return {} + try: + with log_file.open() as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + _logger.warning("Could not read workout log for manual-workout budget check") + return {} + + +def count_in_window( + log_file: Path, + days: int, + *, + today: str | None = None, +) -> int: + """Return how many ``manual_workout`` entries fall in the trailing 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) + logs = _load_logs(log_file) + count = 0 + for date_str, entry in logs.items(): + parsed = _parse_iso(date_str) + if parsed is None or not (cutoff < parsed <= today_dt): + continue + workout_data = entry.get("workout_data", {}) if isinstance(entry, dict) else {} + if workout_data.get("type") == MANUAL_WORKOUT_TYPE: + count += 1 + return count + + +def is_budget_exhausted( + log_file: Path, + *, + today: str | None = None, +) -> bool: + """Return True if either rolling window has reached its manual-workout budget.""" + return ( + count_in_window(log_file, 7, today=today) >= MANUAL_WORKOUT_BUDGET_PER_7_DAYS + or count_in_window(log_file, 30, today=today) + >= MANUAL_WORKOUT_BUDGET_PER_30_DAYS + ) + + +def budget_summary( + log_file: Path, + *, + today: str | None = None, +) -> str: + """One-line UI summary string for the manual-workout budget.""" + week = count_in_window(log_file, 7, today=today) + month = count_in_window(log_file, 30, today=today) + return ( + f"Manual: {week}/{MANUAL_WORKOUT_BUDGET_PER_7_DAYS}w · " + f"{month}/{MANUAL_WORKOUT_BUDGET_PER_30_DAYS}m" + ) + + +@dataclass +class ManualWorkoutDraft: + """User-supplied evidence fields for a manual (unverified) workout. + + Fields shared by every sport come first (required unless noted). + Sport-specific fields follow, grouped by which ``sport`` they apply to — + only the group matching ``sport`` is validated/persisted. + """ + + sport: str + start_time: str # "HH:MM" + end_time: str # "HH:MM" + location_name: str + transport_method: str + cost: str + rpe: int + went_well: str + to_improve: str + overall_feeling: str + location_maps_link: str = "" # optional — awkward to fill from a locked PC + reservation_phone: str = "" + proof_screenshot_path: str = "" # optional — phone-oriented field + techniques_practiced: str = "" + warm_up_minutes: str = "" + pain_or_injury: str = "none" + # SPORT_TABLE_TENNIS fields + matches_won: int = 0 + matches_lost: int = 0 + sets_won: int = 0 + sets_lost: int = 0 + racket: str = "" + balls: str = "" + # SPORT_OTHER fields + activity_type_other: str = "" + activity_details: str = "" + equipment: str = "" + + +def _parse_hhmm(value: str) -> datetime | None: + """Parse an ``HH:MM`` string into a datetime on an arbitrary fixed date.""" + try: + return datetime.strptime(value.strip(), "%H:%M").replace(tzinfo=timezone.utc) + except ValueError: + return None + + +def _duration_minutes(draft: ManualWorkoutDraft) -> float | None: + """Return ``end_time - start_time`` in minutes, or None if unparsable.""" + start = _parse_hhmm(draft.start_time) + end = _parse_hhmm(draft.end_time) + if start is None or end is None or end <= start: + return None + return (end - start).total_seconds() / 60 + + +def validate_manual_workout(draft: ManualWorkoutDraft) -> str | None: + """Return an error message if the draft is invalid, else None.""" + if draft.sport not in SPORT_CHOICES: + return "Please choose a sport" + + required = { + "Start time": draft.start_time, + "End time": draft.end_time, + "Location name": draft.location_name, + "Transport method": draft.transport_method, + "Cost": draft.cost, + } + for label, value in required.items(): + if not value.strip(): + return f"{label} is required" + + duration = _duration_minutes(draft) + if duration is None: + return "Start/end time must be valid HH:MM, with end after start" + if duration < MANUAL_WORKOUT_MIN_DURATION_MINUTES: + return ( + f"Session must be at least {MANUAL_WORKOUT_MIN_DURATION_MINUTES} " + f"minutes (currently {duration:.0f})" + ) + + if not MANUAL_WORKOUT_RPE_MIN <= draft.rpe <= MANUAL_WORKOUT_RPE_MAX: + return ( + f"RPE must be between {MANUAL_WORKOUT_RPE_MIN} and {MANUAL_WORKOUT_RPE_MAX}" + ) + + if draft.sport == SPORT_TABLE_TENNIS: + error = _validate_table_tennis(draft) + else: + error = _validate_other_sport(draft) + if error is not None: + return error + + for label, value in ( + ("What went well", draft.went_well), + ("What to improve", draft.to_improve), + ("Overall feeling", draft.overall_feeling), + ): + if len(value.strip()) < MANUAL_WORKOUT_REFLECTION_MIN_CHARS: + return ( + f"{label} must be at least {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} " + f"characters (currently {len(value.strip())})" + ) + + return None + + +def _validate_table_tennis(draft: ManualWorkoutDraft) -> str | None: + """Validate the table-tennis-specific fields of a draft.""" + for label, value in ( + ("Matches won", draft.matches_won), + ("Matches lost", draft.matches_lost), + ("Sets won", draft.sets_won), + ("Sets lost", draft.sets_lost), + ): + if value < 0: + return f"{label} cannot be negative" + if draft.matches_won + draft.matches_lost == 0: + return "Enter at least one match played (won + lost)" + if not draft.racket.strip(): + return "Racket is required" + if not draft.balls.strip(): + return "Balls are required" + return None + + +def _validate_other_sport(draft: ManualWorkoutDraft) -> str | None: + """Validate the generic "other sport" fields of a draft.""" + if not draft.activity_type_other.strip(): + return "Activity type is required" + if len(draft.activity_details.strip()) < MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS: + return ( + "What was done must be at least " + f"{MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS} characters " + f"(currently {len(draft.activity_details.strip())})" + ) + return None + + +def build_entry(draft: ManualWorkoutDraft) -> dict[str, object]: + """Convert a validated draft into a ``workout_data`` dict for the log.""" + duration = _duration_minutes(draft) + if draft.sport == SPORT_TABLE_TENNIS: + activity_label = "table tennis" + else: + activity_label = draft.activity_type_other.strip() + + entry: dict[str, object] = { + "type": MANUAL_WORKOUT_TYPE, + "source": f"{activity_label} at {draft.location_name.strip()}", + "sport": draft.sport, + "activity_type": activity_label, + "start_time": draft.start_time.strip(), + "end_time": draft.end_time.strip(), + "duration_minutes": f"{duration:.1f}" if duration is not None else "", + "location_name": draft.location_name.strip(), + "location_maps_link": draft.location_maps_link.strip(), + "transport_method": draft.transport_method.strip(), + "cost": draft.cost.strip(), + "reservation_phone": draft.reservation_phone.strip(), + "proof_screenshot_path": draft.proof_screenshot_path.strip(), + "rpe": int(draft.rpe), + "techniques_practiced": draft.techniques_practiced.strip(), + "warm_up_minutes": draft.warm_up_minutes.strip(), + "pain_or_injury": draft.pain_or_injury.strip(), + "went_well": draft.went_well.strip(), + "to_improve": draft.to_improve.strip(), + "overall_feeling": draft.overall_feeling.strip(), + } + if draft.sport == SPORT_TABLE_TENNIS: + entry.update( + { + "matches_won": int(draft.matches_won), + "matches_lost": int(draft.matches_lost), + "sets_won": int(draft.sets_won), + "sets_lost": int(draft.sets_lost), + "racket": draft.racket.strip(), + "balls": draft.balls.strip(), + } + ) + else: + entry.update( + { + "activity_details": draft.activity_details.strip(), + "equipment": draft.equipment.strip(), + } + ) + return entry diff --git a/screen_locker/_manual_workout_dialog.py b/screen_locker/_manual_workout_dialog.py new file mode 100644 index 0000000..a18f07a --- /dev/null +++ b/screen_locker/_manual_workout_dialog.py @@ -0,0 +1,357 @@ +"""Manual (unverified) workout evidence-form dialog mixin. + +Composed onto both ``ScreenLocker`` (locked-out entry point) and +``StatusWindow`` (voluntary, anytime entry point). Host classes must +implement two hooks: + +- ``_on_manual_workout_saved(entry: dict) -> None`` — called with the built + ``workout_data`` dict once the form validates; the host decides how to + persist it (and, for ``ScreenLocker``, unlock afterward). +- ``_on_manual_workout_cancelled() -> None`` — called when BACK is pressed. + +The form's activity-details section is sport-specific (see +``_manual_workout.SPORT_CHOICES``): choosing a sport swaps in that sport's +fields via ``_on_mw_sport_changed`` rather than one generic free-text blob. +""" + +from __future__ import annotations + +import logging +import tkinter as tk + +from screen_locker import _manual_workout +from screen_locker._constants import ( + MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS, + MANUAL_WORKOUT_REFLECTION_MIN_CHARS, + MANUAL_WORKOUT_RPE_MAX, + MANUAL_WORKOUT_RPE_MIN, +) +from screen_locker._ui_widgets import disable_paste + +_logger = logging.getLogger(__name__) + +_SPORT_LABEL_TO_CODE = { + label: code for code, label in _manual_workout.SPORT_LABELS.items() +} + + +class ManualWorkoutDialogMixin: + """Renders the manual-workout evidence form and handles submission.""" + + def _show_manual_workout_form(self) -> None: + """Render the manual-workout evidence form, or a budget-exhausted note.""" + self.clear_container() + self._label("Log Manual Workout", color="#0088cc", pady=10) + if _manual_workout.is_budget_exhausted(self.log_file): + self._text( + "Manual-workout budget exhausted for this window.", + color="#ff4444", + ) + self._text(_manual_workout.budget_summary(self.log_file), color="#888888") + row = self._button_row() + self._button( + row, + "BACK", + bg="#aa0000", + command=self._on_manual_workout_cancelled, + width=12, + ).pack(side="left", padx=10) + return + self._text(_manual_workout.budget_summary(self.log_file), color="#88ccff") + self._build_manual_workout_form() + + def _build_manual_workout_form(self) -> None: + """Build the scrollable evidence form and submit/back buttons.""" + outer = tk.Frame(self.container, bg="#1a1a1a") + outer.pack(fill="both", expand=True, pady=10) + canvas = tk.Canvas(outer, bg="#1a1a1a", highlightthickness=0) + scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview) + form = tk.Frame(canvas, bg="#1a1a1a") + form.bind( + "", + lambda _e: canvas.configure(scrollregion=canvas.bbox("all")), + ) + canvas.create_window((0, 0), window=form, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + self._mw_vars: dict[str, tk.StringVar] = {} + self._mw_int_vars: dict[str, tk.IntVar] = {} + self._mw_rpe_var = tk.IntVar(value=5) + self._mw_text_widgets: dict[str, tk.Text] = {} + + self._mw_section(form, "Basics") + self._mw_sport_row(form) + self._mw_vars["start_time"] = self._mw_entry(form, "Start time (HH:MM):") + self._mw_vars["end_time"] = self._mw_entry(form, "End time (HH:MM):") + + self._mw_section(form, "Location & logistics") + self._mw_vars["location_name"] = self._mw_entry(form, "Location name:") + self._mw_vars["location_maps_link"] = self._mw_entry( + form, + "Google Maps link (optional — easier to fill from your phone):", + ) + self._mw_vars["transport_method"] = self._mw_entry( + form, "How did you get there?" + ) + self._mw_vars["cost"] = self._mw_entry(form, "Cost (e.g. 40 PLN):") + self._mw_vars["reservation_phone"] = self._mw_entry( + form, "Reservation phone number (if booked by phone, optional):" + ) + + self._mw_section(form, "Evidence") + self._mw_vars["proof_screenshot_path"] = self._mw_entry( + form, + "Screenshot path as proof of arrangement " + "(optional — phone-oriented field):", + ) + + self._mw_section(form, "Activity details") + self._mw_tt_frame = tk.Frame(form, bg="#1a1a1a") + self._mw_other_frame = tk.Frame(form, bg="#1a1a1a") + self._build_table_tennis_fields(self._mw_tt_frame) + self._build_other_sport_fields(self._mw_other_frame) + self._mw_rpe_row(form) + self._mw_vars["techniques_practiced"] = self._mw_entry( + form, "Techniques/focus areas practiced (optional):" + ) + self._mw_vars["warm_up_minutes"] = self._mw_entry( + form, "Warm-up duration (optional):" + ) + self._mw_vars["pain_or_injury"] = self._mw_entry( + form, "Pain or injury notes (optional, default none):" + ) + self._on_mw_sport_changed( + _manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS] + ) + + self._mw_section(form, "Reflection") + self._mw_text_widgets["went_well"] = self._mw_textbox( + form, + f"What went well (min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):", + ) + self._mw_text_widgets["to_improve"] = self._mw_textbox( + form, + f"What to improve (min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):", + ) + self._mw_text_widgets["overall_feeling"] = self._mw_textbox( + form, + "Overall feeling about the session " + f"(min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):", + ) + + self._mw_error_label = self._text("", color="#ff4444", pady=5) + button_row = self._button_row() + self._button( + button_row, + "SUBMIT", + bg="#0066cc", + command=self._submit_manual_workout_form, + width=12, + ).pack(side="left", padx=10) + self._button( + button_row, + "BACK", + bg="#aa0000", + command=self._on_manual_workout_cancelled, + width=12, + ).pack(side="left", padx=10) + + def _mw_section(self, parent: tk.Widget, title: str) -> None: + """Add a section heading inside the form.""" + tk.Label( + parent, + text=title, + font=("Arial", 16, "bold"), + fg="#88ccff", + bg="#1a1a1a", + anchor="w", + ).pack(fill="x", pady=(15, 2)) + + def _mw_sport_row(self, parent: tk.Widget) -> None: + """Add the sport-selector dropdown; swaps the activity-details section.""" + self._mw_sport_var = tk.StringVar( + value=_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS] + ) + row = tk.Frame(parent, bg="#1a1a1a") + row.pack(pady=5, fill="x") + tk.Label( + row, + text="Sport:", + font=("Arial", 14), + fg="white", + bg="#1a1a1a", + ).pack(side="left", padx=5) + tk.OptionMenu( + row, + self._mw_sport_var, + *_manual_workout.SPORT_LABELS.values(), + command=self._on_mw_sport_changed, + ).pack(side="left", padx=5) + + def _on_mw_sport_changed(self, selected_label: str) -> None: + """Show the fields for the newly-selected sport, hide the other's.""" + sport = _SPORT_LABEL_TO_CODE.get( + selected_label, _manual_workout.SPORT_TABLE_TENNIS + ) + if sport == _manual_workout.SPORT_TABLE_TENNIS: + self._mw_other_frame.pack_forget() + self._mw_tt_frame.pack(fill="x") + else: + self._mw_tt_frame.pack_forget() + self._mw_other_frame.pack(fill="x") + + def _build_table_tennis_fields(self, parent: tk.Widget) -> None: + """Build the table-tennis-specific score/equipment fields.""" + self._mw_int_vars["matches_won"] = self._mw_int_field(parent, "Matches won:") + self._mw_int_vars["matches_lost"] = self._mw_int_field(parent, "Matches lost:") + self._mw_int_vars["sets_won"] = self._mw_int_field(parent, "Sets won:") + self._mw_int_vars["sets_lost"] = self._mw_int_field(parent, "Sets lost:") + self._mw_vars["racket"] = self._mw_entry(parent, "Racket used:") + self._mw_vars["balls"] = self._mw_entry(parent, "Balls used:") + + def _build_other_sport_fields(self, parent: tk.Widget) -> None: + """Build the generic "other sport" fields.""" + self._mw_vars["activity_type_other"] = self._mw_entry( + parent, "What sport/activity:" + ) + self._mw_text_widgets["activity_details"] = self._mw_textbox( + parent, + f"What was done (min {MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS} chars):", + ) + self._mw_vars["equipment"] = self._mw_entry( + parent, "Equipment used (optional):" + ) + + def _mw_entry(self, parent: tk.Widget, label: str) -> tk.StringVar: + """Add a label+entry field and return its backing StringVar.""" + var = tk.StringVar() + self._add_label_entry(parent, label=label, variable=var) + return var + + def _mw_int_field( + self, parent: tk.Widget, label: str, *, frm: int = 0, to: int = 99 + ) -> tk.IntVar: + """Add a label + numeric Spinbox field and return its backing IntVar.""" + var = tk.IntVar(value=0) + row = tk.Frame(parent, bg="#1a1a1a") + row.pack(pady=5, fill="x") + tk.Label( + row, + text=label, + font=("Arial", 14), + fg="white", + bg="#1a1a1a", + ).pack(side="left", padx=5) + tk.Spinbox( + row, + from_=frm, + to=to, + textvariable=var, + width=4, + font=("Arial", 14), + ).pack(side="left", padx=5) + return var + + def _mw_textbox(self, parent: tk.Widget, label: str) -> tk.Text: + """Add a label + multi-line Text field and return the widget.""" + tk.Label( + parent, + text=label, + font=("Arial", 14), + fg="white", + bg="#1a1a1a", + anchor="w", + ).pack(fill="x", pady=(5, 0)) + text_widget = tk.Text( + parent, + width=60, + height=4, + font=("Arial", 12), + bg="#2a2a2a", + fg="white", + insertbackground="white", + ) + text_widget.pack(pady=2, fill="x") + disable_paste(text_widget) + return text_widget + + def _mw_rpe_row(self, parent: tk.Widget) -> None: + """Add the RPE (rate of perceived exertion) spinbox row.""" + row = tk.Frame(parent, bg="#1a1a1a") + row.pack(pady=5, fill="x") + tk.Label( + row, + text=( + f"RPE — perceived exertion " + f"({MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}):" + ), + font=("Arial", 14), + fg="white", + bg="#1a1a1a", + ).pack(side="left", padx=5) + tk.Spinbox( + row, + from_=MANUAL_WORKOUT_RPE_MIN, + to=MANUAL_WORKOUT_RPE_MAX, + textvariable=self._mw_rpe_var, + width=4, + font=("Arial", 14), + ).pack(side="left", padx=5) + + def _current_mw_sport(self) -> str: + """Return the internal sport code for the currently-selected label.""" + return _SPORT_LABEL_TO_CODE.get( + self._mw_sport_var.get(), _manual_workout.SPORT_TABLE_TENNIS + ) + + def _mw_int_value(self, key: str) -> int: + """Read an int Spinbox var, defaulting to 0 on an invalid value.""" + try: + return int(self._mw_int_vars[key].get()) + except (tk.TclError, ValueError): + return 0 + + def _submit_manual_workout_form(self) -> None: + """Validate the form and either show an error or save + notify host.""" + try: + rpe = int(self._mw_rpe_var.get()) + except (tk.TclError, ValueError): + rpe = 0 + sport = self._current_mw_sport() + draft = _manual_workout.ManualWorkoutDraft( + sport=sport, + start_time=self._mw_vars["start_time"].get(), + end_time=self._mw_vars["end_time"].get(), + location_name=self._mw_vars["location_name"].get(), + transport_method=self._mw_vars["transport_method"].get(), + cost=self._mw_vars["cost"].get(), + rpe=rpe, + went_well=self._mw_text_widgets["went_well"].get("1.0", "end"), + to_improve=self._mw_text_widgets["to_improve"].get("1.0", "end"), + overall_feeling=self._mw_text_widgets["overall_feeling"].get("1.0", "end"), + location_maps_link=self._mw_vars["location_maps_link"].get(), + reservation_phone=self._mw_vars["reservation_phone"].get(), + proof_screenshot_path=self._mw_vars["proof_screenshot_path"].get(), + techniques_practiced=self._mw_vars["techniques_practiced"].get(), + warm_up_minutes=self._mw_vars["warm_up_minutes"].get(), + pain_or_injury=self._mw_vars["pain_or_injury"].get() or "none", + matches_won=self._mw_int_value("matches_won"), + matches_lost=self._mw_int_value("matches_lost"), + sets_won=self._mw_int_value("sets_won"), + sets_lost=self._mw_int_value("sets_lost"), + racket=self._mw_vars["racket"].get(), + balls=self._mw_vars["balls"].get(), + activity_type_other=self._mw_vars["activity_type_other"].get(), + activity_details=self._mw_text_widgets["activity_details"].get( + "1.0", "end" + ), + equipment=self._mw_vars["equipment"].get(), + ) + error = _manual_workout.validate_manual_workout(draft) + if error is not None: + self._mw_error_label.config(text=error) + return + entry = _manual_workout.build_entry(draft) + self._on_manual_workout_saved(entry) diff --git a/screen_locker/_shutdown.py b/screen_locker/_shutdown.py index 1b1cee8..1bc4a8a 100644 --- a/screen_locker/_shutdown.py +++ b/screen_locker/_shutdown.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone import json import logging import subprocess +from typing import TYPE_CHECKING from screen_locker._constants import ( ADJUST_SHUTDOWN_SCRIPT, @@ -17,8 +18,39 @@ from screen_locker._constants import ( WAKE_AFTER_HOURS, ) +if TYPE_CHECKING: + from pathlib import Path + _logger = logging.getLogger(__name__) +_SHUTDOWN_CONFIG_KEYS = ("MON_WED_HOUR", "THU_SUN_HOUR", "MORNING_END_HOUR") + + +def read_shutdown_config(path: Path) -> tuple[int, int, int] | None: + """Read shutdown config from *path*. Returns (mw_hour, ts_hour, me_hour) or None. + + Reading needs no privilege (only writing does, via + ``adjust_shutdown_schedule.sh``) — safe to call from a read-only status view. + """ + if not path.exists(): + _logger.warning("Config not found: %s", path) + return None + parsed: dict[str, int] = {} + with path.open() as f: + for line in f: + stripped = line.strip() + for key in _SHUTDOWN_CONFIG_KEYS: + if stripped.startswith(f"{key}="): + parsed[key] = int(stripped.split("=")[1]) + if len(parsed) < len(_SHUTDOWN_CONFIG_KEYS): + _logger.warning("Shutdown config missing required values") + return None + return ( + parsed["MON_WED_HOUR"], + parsed["THU_SUN_HOUR"], + parsed["MORNING_END_HOUR"], + ) + class ShutdownMixin: """Mixin providing shutdown schedule adjustment functionality.""" @@ -199,25 +231,7 @@ class ShutdownMixin: def _read_shutdown_config(self) -> tuple[int, int, int] | None: """Read shutdown config. Returns (mw_hour, ts_hour, me_hour) or None.""" - if not SHUTDOWN_CONFIG_FILE.exists(): - _logger.warning("Config not found: %s", SHUTDOWN_CONFIG_FILE) - return None - parsed: dict[str, int] = {} - keys = ("MON_WED_HOUR", "THU_SUN_HOUR", "MORNING_END_HOUR") - with SHUTDOWN_CONFIG_FILE.open() as f: - for line in f: - stripped = line.strip() - for key in keys: - if stripped.startswith(f"{key}="): - parsed[key] = int(stripped.split("=")[1]) - if len(parsed) < len(keys): - _logger.warning("Shutdown config missing required values") - return None - return ( - parsed["MON_WED_HOUR"], - parsed["THU_SUN_HOUR"], - parsed["MORNING_END_HOUR"], - ) + return read_shutdown_config(SHUTDOWN_CONFIG_FILE) def _build_shutdown_cmd( self, diff --git a/screen_locker/_sick_dialog.py b/screen_locker/_sick_dialog.py index 3c2921b..f9ad45b 100644 --- a/screen_locker/_sick_dialog.py +++ b/screen_locker/_sick_dialog.py @@ -2,7 +2,6 @@ from __future__ import annotations -import contextlib import logging import tkinter as tk from typing import TYPE_CHECKING @@ -13,6 +12,7 @@ from screen_locker._constants import ( SICK_COMMITMENT_FORCED_READ_SECONDS, SICK_JUSTIFICATION_MIN_CHARS, ) +from screen_locker._ui_widgets import disable_paste as _disable_paste if TYPE_CHECKING: from collections.abc import Callable @@ -22,18 +22,6 @@ if TYPE_CHECKING: _logger = logging.getLogger(__name__) -def _disable_paste(widget: tk.Widget) -> None: - """Disable paste in a Tk Entry/Text widget. - - Friction-only: a determined user can still bypass via xdotool, but the - point is removing the trivial Ctrl+V shortcut so the user must - actually type their justification. - """ - for sequence in ("<>", "", "", ""): - with contextlib.suppress(tk.TclError, AttributeError): - widget.bind(sequence, lambda _e: "break") - - class SickDialogMixin: """Renders the sick-day justification screen and commitment prompts.""" @@ -153,36 +141,6 @@ class SickDialogMixin: _disable_paste(text_widget) return text_widget - def _add_label_entry( - self, - parent: tk.Widget, - *, - label: str, - variable: tk.StringVar, - ) -> None: - """Add a label + single-line entry pair, with paste disabled.""" - row = tk.Frame(parent, bg="#1a1a1a") - row.pack(pady=5, fill="x") - tk.Label( - row, - text=label, - font=("Arial", 14), - fg="white", - bg="#1a1a1a", - anchor="w", - ).pack(side="top", anchor="w") - entry = tk.Entry( - row, - textvariable=variable, - width=50, - font=("Arial", 14), - bg="#2a2a2a", - fg="white", - insertbackground="white", - ) - entry.pack(side="top", anchor="w", pady=2) - _disable_paste(entry) - def _update_commitment_forced_delay(self) -> None: """Tick down the forced-read delay then enable the submit button.""" if self._commitment_forced_remaining > 0: diff --git a/screen_locker/_status_data.py b/screen_locker/_status_data.py new file mode 100644 index 0000000..f50e1f9 --- /dev/null +++ b/screen_locker/_status_data.py @@ -0,0 +1,371 @@ +"""Read-only status snapshot: today, this week, shutdown projection, sick budget. + +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 +existing, tested, deliberately *mutating* CLI used by a human running +``--status`` who already expects that); this module is the new, genuinely +side-effect-free layer that both the i3blocks summary line and the Tkinter +status view are built on. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from screen_locker import _manual_workout +from screen_locker._compliance_state import LockExplanation, explain_lock_decision +from screen_locker._constants import ( + EARLY_BIRD_PENDING_FILE, + EXTRA_BENEFITS_FILE, + MANUAL_WORKOUT_BUDGET_PER_7_DAYS, + MANUAL_WORKOUT_BUDGET_PER_30_DAYS, + SCHEDULED_SKIPS_FILE, + SHUTDOWN_BASE_FILE, + 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, + has_extended_early_bird, + preview_bonus_if_week_ended_now, + weekly_shutdown_bonus_hours, +) +from screen_locker._shutdown import read_shutdown_config +from screen_locker._shutdown_base import get_base_hours +from screen_locker._sick_tracker import ( + count_in_window, + is_budget_exhausted, + load_history, +) +from screen_locker._wake_state import has_workout_skip_today +from screen_locker._weekly_check import ( + COUNTED_WORKOUT_TYPES, + WEEKLY_WORKOUT_MINIMUM, + count_weekly_workouts, + is_relaxed_day, +) + +if TYPE_CHECKING: + from screen_locker._sick_tracker import SickHistory + +_DEFAULT_LOG_FILE = Path(__file__).resolve().parent / "workout_log.json" +_RELAXED_DAY_EXPLANATION = ( + "Shutdown time and the screen lock are two separate systems: the lock " + "decides whether you must log a workout right now; shutdown time only " + "controls how late the PC stays on tonight." +) +_WEEKDAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") +_MON_WED_WEEKDAYS = frozenset({0, 1, 2}) + + +@dataclass(frozen=True) +class DayStatus: + """One day's workout outcome.""" + + date: str + label: str + entry_type: str | None + source: str + counted: bool + is_sick_day: bool + + +@dataclass(frozen=True) +class WeeklySummary: + """This ISO week's workout compliance so far.""" + + days: tuple[DayStatus, ...] + counted_count: int + minimum: int + remaining: int + 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.""" + + used_7d: int + budget_7d: int + used_30d: int + budget_30d: int + exhausted: bool + + +@dataclass(frozen=True) +class ShutdownProjectionDay: + """One labeled row in a shutdown-time projection.""" + + label: str + hour: int + speculative: bool + + +@dataclass(frozen=True) +class ShutdownProjection: + """Tonight's live config plus deterministic/speculative week projections.""" + + tonight: tuple[int, int, int] | None + rest_of_week: tuple[ShutdownProjectionDay, ...] + next_week_preview: tuple[ShutdownProjectionDay, ...] + explanation: str + + +@dataclass(frozen=True) +class StatusSnapshot: + """Everything the status view needs, gathered in one read-only pass.""" + + today: DayStatus + week: WeeklySummary + bonus_hours_this_week: int + streak: int + early_bird_extended: bool + shutdown: ShutdownProjection + lock_explanation: LockExplanation + sick_budget: SickBudgetStatus + manual_workout_budget: ManualWorkoutBudgetStatus + generated_at: str + + +def _day_status(day_date: date, entry: dict | None, sick_days: set[str]) -> DayStatus: + """Build a :class:`DayStatus` for one calendar day.""" + iso = day_date.isoformat() + label = day_date.strftime("%a %b %d") + if entry is None: + return DayStatus( + date=iso, + label=label, + entry_type=None, + source="", + counted=False, + is_sick_day=iso in sick_days, + ) + workout_data = entry.get("workout_data", {}) if isinstance(entry, dict) else {} + entry_type = workout_data.get("type") + return DayStatus( + date=iso, + label=label, + entry_type=entry_type, + source=workout_data.get("source", ""), + counted=entry_type in COUNTED_WORKOUT_TYPES, + is_sick_day=iso in sick_days, + ) + + +def _load_log(log_file: Path) -> dict: + """Load the workout log dict, returning {} on any error.""" + if not log_file.exists(): + return {} + try: + with log_file.open() as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return {} + + +def _week_days( + log_file: Path, sick_history: SickHistory, *, today_local: datetime +) -> tuple[DayStatus, ...]: + """Return this ISO week's days from Monday through today.""" + log_data = _load_log(log_file) + sick_days = set(sick_history.sick_days) + today_date = today_local.date() + monday = today_date - timedelta(days=today_date.weekday()) + days: list[DayStatus] = [] + day = monday + while day <= today_date: + days.append(_day_status(day, log_data.get(day.isoformat()), sick_days)) + day += timedelta(days=1) + return tuple(days) + + +def _weekly_summary(days: tuple[DayStatus, ...]) -> WeeklySummary: + """Summarize a week's days against the weekly minimum.""" + counted = sum(1 for d in days if d.counted) + return WeeklySummary( + days=days, + counted_count=counted, + minimum=WEEKLY_WORKOUT_MINIMUM, + remaining=max(0, WEEKLY_WORKOUT_MINIMUM - counted), + extra=max(0, counted - WEEKLY_WORKOUT_MINIMUM), + ) + + +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: + """Summarize rolling manual-workout budget usage.""" + return ManualWorkoutBudgetStatus( + used_7d=_manual_workout.count_in_window(log_file, 7, today=today), + budget_7d=MANUAL_WORKOUT_BUDGET_PER_7_DAYS, + used_30d=_manual_workout.count_in_window(log_file, 30, today=today), + budget_30d=MANUAL_WORKOUT_BUDGET_PER_30_DAYS, + exhausted=_manual_workout.is_budget_exhausted(log_file, today=today), + ) + + +def _week_rows( + mon_wed_hour: int, thu_sun_hour: int, *, speculative: bool +) -> tuple[ShutdownProjectionDay, ...]: + """Build 7 labeled rows, one per weekday, from a Mon-Wed/Thu-Sun band pair.""" + return tuple( + ShutdownProjectionDay( + label=label, + hour=mon_wed_hour if weekday in _MON_WED_WEEKDAYS else thu_sun_hour, + speculative=speculative, + ) + for weekday, label in enumerate(_WEEKDAY_LABELS) + ) + + +def _shutdown_projection( + *, + shutdown_base_file: Path, + shutdown_config_file: Path, + extra_benefits_file: Path, + log_file: Path, + today_local: datetime, +) -> ShutdownProjection: + """Build the shutdown-time projection: tonight, rest of week, next week.""" + tonight = read_shutdown_config(shutdown_config_file) + base_mw, base_ts = get_base_hours(shutdown_base_file) + bonus = weekly_shutdown_bonus_hours(extra_benefits_file, today=today_local) + rest_of_week = _week_rows(base_mw + bonus, base_ts + bonus, speculative=False) + + this_week_count = count_weekly_workouts(log_file, today=today_local) + streak = current_streak(extra_benefits_file) + _would_be_streak, would_be_bonus = preview_bonus_if_week_ended_now( + this_week_count, streak + ) + next_week_preview = _week_rows( + base_mw + would_be_bonus, base_ts + would_be_bonus, speculative=True + ) + + return ShutdownProjection( + tonight=tonight, + rest_of_week=rest_of_week, + next_week_preview=next_week_preview, + explanation=_RELAXED_DAY_EXPLANATION, + ) + + +def gather_status( + *, + log_file: Path = _DEFAULT_LOG_FILE, + extra_benefits_file: Path = EXTRA_BENEFITS_FILE, + shutdown_base_file: Path = SHUTDOWN_BASE_FILE, + shutdown_config_file: Path = SHUTDOWN_CONFIG_FILE, + scheduled_skips_file: Path = SCHEDULED_SKIPS_FILE, + early_bird_pending_file: Path = EARLY_BIRD_PENDING_FILE, + now: datetime | None = None, +) -> StatusSnapshot: + """Gather a full :class:`StatusSnapshot` from on-disk state only. + + Never touches ADB, sudo, or the network — safe to call on every i3blocks + tick and every status-window open/refresh. + """ + instant = now if now is not None else datetime.now(tz=timezone.utc) + 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) + week = _weekly_summary(days) + early_bird_extended = has_extended_early_bird( + extra_benefits_file, today=today_local + ) + + lock_explanation = explain_lock_decision( + log_file=log_file, + scheduled_skips_file=scheduled_skips_file, + early_bird_pending_file=early_bird_pending_file, + sick_history=sick_history, + extended_early_bird=early_bird_extended, + weekly_minimum_met=week.counted_count >= WEEKLY_WORKOUT_MINIMUM, + relaxed_day=is_relaxed_day(today=today_local), + wake_skip=has_workout_skip_today(), + now=instant, + ) + + return StatusSnapshot( + today=days[-1], + week=week, + bonus_hours_this_week=weekly_shutdown_bonus_hours( + extra_benefits_file, today=today_local + ), + streak=current_streak(extra_benefits_file), + early_bird_extended=early_bird_extended, + shutdown=_shutdown_projection( + shutdown_base_file=shutdown_base_file, + shutdown_config_file=shutdown_config_file, + extra_benefits_file=extra_benefits_file, + log_file=log_file, + 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(), + ) + + +def _tonight_hour(snapshot: StatusSnapshot) -> int | None: + """Pick the shutdown hour band (Mon-Wed vs Thu-Sun) matching today. + + Uses ``snapshot.generated_at`` (not a fresh ``datetime.now()``) so the + summary line is a pure function of the snapshot it was handed. + """ + if snapshot.shutdown.tonight is None: + return None + mon_wed_hour, thu_sun_hour, _morning = snapshot.shutdown.tonight + generated_weekday = ( + datetime.fromisoformat(snapshot.generated_at).astimezone().weekday() + ) + return mon_wed_hour if generated_weekday in _MON_WED_WEEKDAYS else thu_sun_hour + + +def format_summary_line(snapshot: StatusSnapshot) -> str: + """Cheap one-liner for the i3blocks summary (e.g. before/after a click).""" + week = snapshot.week + mark = "✓" if week.remaining == 0 else "…" + tonight_hour = _tonight_hour(snapshot) + tonight_str = f"{tonight_hour:02d}:00" if tonight_hour is not None else "?" + sick = snapshot.sick_budget + return ( + f"{mark} {week.counted_count}/{week.minimum} workouts · " + f"{tonight_str} tonight · sick {sick.used_7d}/{sick.budget_7d}" + ) diff --git a/screen_locker/_ui_flows.py b/screen_locker/_ui_flows.py index fca4530..8eec6da 100644 --- a/screen_locker/_ui_flows.py +++ b/screen_locker/_ui_flows.py @@ -5,7 +5,7 @@ from __future__ import annotations from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module from typing import TYPE_CHECKING -from screen_locker import _sick_tracker +from screen_locker import _manual_workout, _sick_tracker from screen_locker._constants import ( NO_PHONE_EXTRA_LOCKOUT_SECONDS, PHONE_PENALTY_DELAY_DEMO, @@ -44,6 +44,7 @@ class UIFlowsMixin: self._text(message, color="#ffaa00") history = _sick_tracker.load_history() self._text(_sick_tracker.budget_summary(history), color="#888888") + self._text(_manual_workout.budget_summary(self.log_file), color="#888888") frame = self._button_row() self._button( frame, @@ -65,6 +66,33 @@ class UIFlowsMixin: command=self.ask_if_sick, width=12, ).pack(side="left", padx=10) + if _manual_workout.is_budget_exhausted(self.log_file): + self._text( + "Manual-workout budget exhausted. No manual-log option available.", + color="#ff6666", + ) + else: + self._button( + frame, + "Log Manual Workout", + bg="#0088cc", + command=self._show_manual_workout_form, + width=16, + ).pack(side="left", padx=10) + + def _on_manual_workout_saved(self, entry: dict) -> None: + """Show confirmation and unlock after a manual-workout entry is built.""" + self.workout_data = entry + self.clear_container() + self._label("✓ Manual Workout Logged!", font_size=42, color="#00cc44", pady=30) + self._text(entry.get("source", ""), font_size=20, color="#aaffaa") + self._text("Unlocking...", font_size=18, color="#888888") + unlock_delay = 1500 if self.demo_mode else 2000 + self.root.after(unlock_delay, self.unlock_screen) + + def _on_manual_workout_cancelled(self) -> None: + """Return to the retry screen when the manual-workout form is cancelled.""" + self._start_phone_check() def _handle_startup_phone_result(self, status: str, message: str) -> None: """Route to appropriate screen based on startup phone check result.""" diff --git a/screen_locker/_ui_widgets.py b/screen_locker/_ui_widgets.py index 7a08fe3..48a1241 100644 --- a/screen_locker/_ui_widgets.py +++ b/screen_locker/_ui_widgets.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import tkinter as tk from typing import TYPE_CHECKING @@ -9,6 +10,18 @@ if TYPE_CHECKING: from collections.abc import Callable +def disable_paste(widget: tk.Widget) -> None: + """Disable paste in a Tk Entry/Text widget. + + Friction-only: a determined user can still bypass via xdotool, but the + point is removing the trivial Ctrl+V shortcut so the user must + actually type their input. + """ + for sequence in ("<>", "", "", ""): + with contextlib.suppress(tk.TclError, AttributeError): + widget.bind(sequence, lambda _e: "break") + + class UIWidgetsMixin: """Mixin providing low-level widget creation helpers.""" @@ -81,3 +94,33 @@ class UIWidgetsMixin: frame = tk.Frame(self.container, bg="#1a1a1a") frame.pack(pady=20) return frame + + def _add_label_entry( + self, + parent: tk.Widget, + *, + label: str, + variable: tk.StringVar, + ) -> None: + """Add a label + single-line entry pair, with paste disabled.""" + row = tk.Frame(parent, bg="#1a1a1a") + row.pack(pady=5, fill="x") + tk.Label( + row, + text=label, + font=("Arial", 14), + fg="white", + bg="#1a1a1a", + anchor="w", + ).pack(side="top", anchor="w") + entry = tk.Entry( + row, + textvariable=variable, + width=50, + font=("Arial", 14), + bg="#2a2a2a", + fg="white", + insertbackground="white", + ) + entry.pack(side="top", anchor="w", pady=2) + disable_paste(entry) diff --git a/screen_locker/_weekly_check.py b/screen_locker/_weekly_check.py index 303d885..488eff0 100644 --- a/screen_locker/_weekly_check.py +++ b/screen_locker/_weekly_check.py @@ -31,7 +31,7 @@ _RELAXED_WEEKDAYS: frozenset[int] = frozenset({1, 2, 3}) # Tue, Wed, Thu # Exported (no leading underscore) so screen_lock.py can share this single # source of truth instead of duplicating the type check. COUNTED_WORKOUT_TYPES: frozenset[str] = frozenset( - {"phone_verified", "runnerup_manual", "runnerup_verified"}, + {"phone_verified", "runnerup_manual", "runnerup_verified", "manual_workout"}, ) diff --git a/screen_locker/_workout_credit.py b/screen_locker/_workout_credit.py new file mode 100644 index 0000000..bed43bb --- /dev/null +++ b/screen_locker/_workout_credit.py @@ -0,0 +1,137 @@ +"""Shared workout-credit logic: save + shutdown bonus + debt-clear. + +Extracted from ``screen_lock.py`` so both the locked-screen flow +(``ScreenLocker.unlock_screen``) and the voluntary ``StatusWindow`` +"Log Manual Workout" path apply the identical reward — the drift between +those two paths having different behavior once caused a real shutdown-bonus +miss (see ``project-lock-disabled-pending-manual-log`` memory). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import logging +from typing import TYPE_CHECKING + +from screen_locker import _sick_tracker +from screen_locker._weekly_check import ( + COUNTED_WORKOUT_TYPES, + WEEKLY_WORKOUT_MINIMUM, + count_weekly_workouts, +) + +if TYPE_CHECKING: + from pathlib import Path + +_logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +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. + + Requires ``self.workout_data``, ``self.log_file``, and the ``LogMixin``/ + ``ShutdownMixin`` methods it calls into. + """ + + workout_data: dict[str, str] + log_file: Path + + def _try_adjust_shutdown_for_workout(self) -> bool: + """Try to adjust shutdown time later for actual workouts.""" + workout_type = self.workout_data.get("type", "") + if workout_type not in COUNTED_WORKOUT_TYPES: + return False + adjusted = self._adjust_shutdown_time_later() + if adjusted: + _logger.info("Shutdown time moved 2 hours 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", + "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 _was_already_counted_today(self) -> bool: + """Check whether today's log entry, before this save, already counted. + + Used to guard :meth:`_apply_workout_credit` against double-crediting: + unlike the locked flow (which only ever unlocks once), the voluntary + ``StatusWindow`` entry point can save more than one workout on the + same day (the manual-workout budget allows up to 2/week), and + ``_adjust_shutdown_time_later`` is additive, not idempotent. + """ + logs = self._load_existing_logs() + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + entry = logs.get(today) + if not isinstance(entry, dict): + return False + workout_data = entry.get("workout_data", {}) + return workout_data.get("type") in COUNTED_WORKOUT_TYPES + + def _apply_workout_credit(self) -> WorkoutCreditResult: + """Persist ``workout_data`` and apply reward credit, once per day. + + Shared by the locked-screen flow (:meth:`ScreenLocker.unlock_screen`) + and the voluntary ``StatusWindow`` "Log Manual Workout" path. + """ + already_counted = self._was_already_counted_today() + # sick_day is already persisted to sick_history.json by + # _finalize_sick_day — workout_log.json is reserved for real outcomes. + if self.workout_data.get("type") != "sick_day": + self.save_workout_log() + + weekly_count = count_weekly_workouts(self.log_file) + if already_counted: + return WorkoutCreditResult( + shutdown_adjusted=False, + new_debt=None, + extra_bonus_delta=0, + weekly_count=weekly_count, + already_counted_today=True, + ) + + shutdown_adjusted = self._try_adjust_shutdown_for_workout() + new_debt = self._clear_debt_on_verified_workout() + + # Extra-workout bonus: +1h per workout above the weekly minimum. + extra_bonus_delta = 0 + if weekly_count > WEEKLY_WORKOUT_MINIMUM: + old_cfg = self._read_shutdown_config() + if old_cfg and self._adjust_shutdown_time_by(1): + new_cfg = self._read_shutdown_config() + if new_cfg: + extra_bonus_delta = new_cfg[1] - old_cfg[1] + + return WorkoutCreditResult( + shutdown_adjusted=shutdown_adjusted, + new_debt=new_debt, + extra_bonus_delta=extra_bonus_delta, + weekly_count=weekly_count, + already_counted_today=False, + ) diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 20d8071..b6a5f41 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -14,7 +14,6 @@ from typing import TYPE_CHECKING from gatelock import GateRoot, LockConfig, LockWindow -from screen_locker import _sick_tracker from screen_locker._auto_upgrade import AutoUpgradeMixin from screen_locker._constants import ( EARLY_BIRD_END_HOUR, @@ -41,6 +40,7 @@ from screen_locker._extra_benefits import ( ) from screen_locker._heat_skip import HeatSkipMixin from screen_locker._log_mixin import LogMixin +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 @@ -51,13 +51,13 @@ from screen_locker._ui_flows import UIFlowsMixin from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin from screen_locker._ui_widgets import UIWidgetsMixin from screen_locker._weekly_check import ( - COUNTED_WORKOUT_TYPES, WEEKLY_WORKOUT_MINIMUM, count_weekly_workouts, has_weekly_minimum, is_relaxed_day, ) from screen_locker._window_setup import WindowSetupMixin +from screen_locker._workout_credit import WorkoutCreditMixin if TYPE_CHECKING: from collections.abc import Callable @@ -101,7 +101,9 @@ class ScreenLocker( EarlyBirdMixin, HeatSkipMixin, LogMixin, + ManualWorkoutDialogMixin, WindowSetupMixin, + WorkoutCreditMixin, ShutdownMixin, PhoneVerificationMixin, RunnerUpVerificationMixin, @@ -185,17 +187,7 @@ class ScreenLocker( # Auto-fill any RunnerUp workouts from earlier in the current ISO week # before any early-exit check, so gaps are closed regardless of today's # logged state (early_bird, sick_day, etc.). - prev_count = count_weekly_workouts(self.log_file) - n_filled = self._scan_and_fill_week_runnerup(self.log_file) - if n_filled: - new_count = count_weekly_workouts(self.log_file) - _logger.info( - "Auto-filled %d RunnerUp workout(s) from TCX exports.", n_filled - ) - # Award +1h for each newly auto-filled workout above the minimum. - bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count)) - if bonus > 0 and self._adjust_shutdown_time_by(bonus): - _logger.info("Auto-fill extra bonus: +%dh shutdown time.", bonus) + self._auto_fill_week_runnerup_bonus() if self._check_today_state_exits(): return # Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced. @@ -216,17 +208,34 @@ class ScreenLocker( # "skip a workout" credit — that mechanic works against the goal of # maximizing weekly workouts, so it was removed in favor of a # shutdown-time-only reward (see _apply_weekly_shutdown_bonus). + self._check_heat_skip_exit() + + def _auto_fill_week_runnerup_bonus(self) -> None: + """Auto-fill missed RunnerUp workouts and award any earned bonus.""" + prev_count = count_weekly_workouts(self.log_file) + n_filled = self._scan_and_fill_week_runnerup(self.log_file) + if not n_filled: + return + new_count = count_weekly_workouts(self.log_file) + _logger.info("Auto-filled %d RunnerUp workout(s) from TCX exports.", n_filled) + # Award +1h for each newly auto-filled workout above the minimum. + bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count)) + if bonus > 0 and self._adjust_shutdown_time_by(bonus): + _logger.info("Auto-fill extra bonus: +%dh shutdown time.", bonus) + + def _check_heat_skip_exit(self) -> None: + """Exit early if today qualifies for the extreme-heat skip dialog.""" hot_temp = is_too_hot(HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD) - if hot_temp is not None: - _logger.info( - "Temperature %.0f°C exceeds threshold — showing heat-skip dialog.", - hot_temp, - ) - if self._show_heat_skip_dialog(hot_temp): - self._save_heat_skip_log(hot_temp) - _logger.info("User skipped workout due to heat (%.0f°C).", hot_temp) - sys.exit(0) - return + if hot_temp is None: + return + _logger.info( + "Temperature %.0f°C exceeds threshold — showing heat-skip dialog.", + hot_temp, + ) + if self._show_heat_skip_dialog(hot_temp): + self._save_heat_skip_log(hot_temp) + _logger.info("User skipped workout due to heat (%.0f°C).", hot_temp) + sys.exit(0) def _apply_weekly_shutdown_bonus(self) -> None: """Layer this week's earned shutdown bonus back on top of the fresh base.""" @@ -234,70 +243,30 @@ class ScreenLocker( if bonus > 0 and self._adjust_shutdown_time_by(bonus): _logger.info("Weekly bonus: +%dh shutdown time this week.", bonus) - def _try_adjust_shutdown_for_workout(self) -> bool: - """Try to adjust shutdown time later for actual workouts.""" - workout_type = self.workout_data.get("type", "") - if workout_type not in COUNTED_WORKOUT_TYPES: - return False - adjusted = self._adjust_shutdown_time_later() - if adjusted: - _logger.info("Shutdown time moved 2 hours 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"): - 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 unlock_screen(self) -> None: - """Save workout log and display success message.""" - # sick_day is already persisted to sick_history.json by - # _finalize_sick_day — workout_log.json is reserved for real outcomes. - if self.workout_data.get("type") != "sick_day": - self.save_workout_log() - shutdown_adjusted = self._try_adjust_shutdown_for_workout() - new_debt = self._clear_debt_on_verified_workout() - - # Extra-workout bonus: +1h per workout above the weekly minimum. - extra_bonus_delta = 0 - weekly_count = count_weekly_workouts(self.log_file) - if weekly_count > WEEKLY_WORKOUT_MINIMUM: - old_cfg = self._read_shutdown_config() - if old_cfg and self._adjust_shutdown_time_by(1): - new_cfg = self._read_shutdown_config() - if new_cfg: - extra_bonus_delta = new_cfg[1] - old_cfg[1] + """Apply workout credit and display success message.""" + credit = self._apply_workout_credit() self.clear_container() self._label("Great job! 💪", font_size=48, color="#00ff00", pady=30) - if shutdown_adjusted: + if credit.shutdown_adjusted: self._text( "Shutdown time +2h later! 🎁", font_size=24, color="#ffaa00", ) - if extra_bonus_delta > 0: - extra_n = weekly_count - WEEKLY_WORKOUT_MINIMUM + if credit.extra_bonus_delta > 0: + extra_n = credit.weekly_count - WEEKLY_WORKOUT_MINIMUM self._text( - f"Extra workout #{extra_n}! +{extra_bonus_delta}h tonight", + f"Extra workout #{extra_n}! +{credit.extra_bonus_delta}h tonight", font_size=20, color="#ffaa00", ) - if new_debt is not None: + if credit.new_debt is not None: self._text( - f"Workout debt: {new_debt}", + f"Workout debt: {credit.new_debt}", font_size=20, - color="#ffaa00" if new_debt > 0 else "#888888", + color="#ffaa00" if credit.new_debt > 0 else "#888888", ) streak = current_streak(EXTRA_BENEFITS_FILE) if streak >= 1: @@ -307,7 +276,11 @@ class ScreenLocker( color="#888888", ) self._text("Screen Unlocked!", font_size=36, pady=20) - if self.workout_data.get("type") in ("phone_verified", "runnerup_verified"): + if self.workout_data.get("type") in ( + "phone_verified", + "runnerup_verified", + "manual_workout", + ): self.root.after( 1500, lambda: self._show_commitment_prompt(on_done=self.close), diff --git a/screen_locker/status_view.py b/screen_locker/status_view.py new file mode 100644 index 0000000..9404dc6 --- /dev/null +++ b/screen_locker/status_view.py @@ -0,0 +1,352 @@ +"""Read-only Tkinter status window plus a lightweight i3blocks summary CLI. + +Opening or refreshing this window only ever reads files already on disk +(via ``_status_data.gather_status``). The "Check Phone" button submits +``PhoneVerificationMixin._verify_phone_workout`` to a background thread — +mirroring the existing submit/poll/``Future`` idiom used by +``_ui_flows_relaxed.py`` — and only ever *displays* the result; it never +calls ``save_workout_log()``. The one deliberate exception is "Log Manual +Workout": a user-initiated, explicit evidence-form submission (see +``_manual_workout_dialog.ManualWorkoutDialogMixin``) — never a silent log. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module +from pathlib import Path +import sys +import tkinter as tk +from typing import TYPE_CHECKING + +from screen_locker import _manual_workout +from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin +from screen_locker._status_data import ( + DayStatus, + ManualWorkoutBudgetStatus, + ShutdownProjection, + SickBudgetStatus, + StatusSnapshot, + WeeklySummary, + format_summary_line, + gather_status, +) +from screen_locker._ui_widgets import UIWidgetsMixin +from screen_locker._weekly_check import ( + WEEKLY_WORKOUT_MINIMUM as _WEEKLY_WORKOUT_MINIMUM, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from concurrent.futures import Future + + from screen_locker._compliance_state import LockExplanation + from screen_locker.screen_lock import ScreenLocker + +_DEFAULT_LOG_FILE = Path(__file__).resolve().parent / "workout_log.json" + + +def _make_bare_verifier(log_file: Path) -> ScreenLocker: + """Build a minimal ``ScreenLocker`` for read-only verification calls. + + Same ``object.__new__`` bypass ``screen_lock.py`` already uses for + ``--status`` — just enough state for ``PhoneVerificationMixin`` methods + to run, no Tk lock UI, no ``__init__`` side effects. + """ + from screen_locker.screen_lock import ScreenLocker + + verifier = object.__new__(ScreenLocker) + verifier.log_file = log_file + verifier.workout_data = {} + return verifier + + +class StatusWindow(UIWidgetsMixin, ManualWorkoutDialogMixin): + """Thin Tk view over a :class:`StatusSnapshot`. + + Formatting logic lives in ``_status_data``, not here. + """ + + def __init__( + self, + root: tk.Tk, + snapshot: StatusSnapshot, + *, + on_refresh: Callable[[], None], + log_file: Path = _DEFAULT_LOG_FILE, + verifier_factory: Callable[[Path], ScreenLocker] = _make_bare_verifier, + ) -> None: + """Build the window's container and render *snapshot* immediately.""" + self.root = root + self.on_refresh = on_refresh + self.log_file = log_file + self.verifier_factory = verifier_factory + self.demo_mode = True # only affects UIWidgetsMixin._button's cursor + self._phone_future: Future[tuple[str, str]] | None = None + self._phone_check_result: tuple[str, str] | None = None + self._manual_workout_saved_message: str | None = None + self.container = tk.Frame(root, bg="#1a1a1a") + self.container.pack(fill="both", expand=True) + self.render(snapshot) + + def render(self, snapshot: StatusSnapshot) -> None: + """Redraw the whole window from *snapshot*.""" + self.clear_container() + self._label("Workout Status", font_size=28, pady=15) + self._section_today(self.container, snapshot.today) + self._section_week(self.container, snapshot.week) + self._section_lock_explanation(self.container, snapshot.lock_explanation) + self._section_sick_budget(self.container, snapshot.sick_budget) + self._section_manual_workout_budget( + self.container, snapshot.manual_workout_budget + ) + self._section_shutdown(self.container, snapshot.shutdown) + if self._phone_check_result is not None: + status, message = self._phone_check_result + color = "#00cc44" if status == "verified" else "#ff8844" + self._text(f"Phone check ({status}): {message}", font_size=13, color=color) + if self._manual_workout_saved_message is not None: + self._text( + self._manual_workout_saved_message, font_size=13, color="#00cc44" + ) + frame = self._button_row() + self._button( + frame, + "Check Phone", + bg="#0066cc", + command=self._on_check_phone_clicked, + width=14, + ).pack(side="left", padx=8) + if not _manual_workout.is_budget_exhausted(self.log_file): + self._button( + frame, + "Log Manual Workout", + bg="#0088cc", + command=self._show_manual_workout_form, + width=16, + ).pack(side="left", padx=8) + self._button( + frame, "Refresh", bg="#006600", command=self._on_refresh_clicked, width=10 + ).pack(side="left", padx=8) + self._button( + frame, "Close", bg="#aa0000", command=self.root.destroy, width=8 + ).pack(side="left", padx=8) + + def _section_today(self, parent: tk.Widget, day: DayStatus) -> None: + """Render today's outcome.""" + del parent + mark = "✓" if day.counted else ("😷" if day.is_sick_day else "—") + entry_str = day.entry_type or ( + "sick day" if day.is_sick_day else "no entry yet" + ) + self._text( + f"{mark} Today ({day.label}): {entry_str}", + font_size=18, + color="#aaffaa" if day.counted else "#ffaa00", + ) + if day.source: + self._text(day.source, font_size=12, color="#888888", pady=2) + + def _section_week(self, parent: tk.Widget, week: WeeklySummary) -> None: + """Render this ISO week's per-day breakdown and totals.""" + del parent + self._label( + f"This Week: {week.counted_count}/{week.minimum}", font_size=18, pady=8 + ) + for day in week.days: + mark = "✓" if day.counted else ("😷" if day.is_sick_day else "·") + entry_str = day.entry_type or ( + "sick day" if day.is_sick_day else "no entry" + ) + self._text( + f"{mark} {day.label}: {entry_str}", + font_size=12, + color="#cccccc", + pady=1, + ) + if week.remaining > 0: + self._text( + f"Need {week.remaining} more this week.", font_size=13, color="#ffaa00" + ) + elif week.extra > 0: + self._text( + f"{week.extra} above the weekly minimum!", font_size=13, color="#00cc44" + ) + + def _section_lock_explanation( + self, parent: tk.Widget, expl: LockExplanation + ) -> None: + """Render why the lock did/didn't fire today, plus its evaluation trace.""" + del parent + self._label("Why the lock did/didn't fire", font_size=16, pady=8) + self._text( + expl.reason, font_size=13, color="#ff4444" if expl.fired else "#00cc44" + ) + if expl.auto_upgrade.would_attempt: + self._text( + f"Pending auto-upgrade: {expl.auto_upgrade.reason}", + font_size=11, + color="#ffaa00", + ) + if expl.fired and not expl.heat_skip_evaluated: + self._text( + "Live Warsaw temperature is not checked here — the real " + "locker may still offer a heat-skip.", + font_size=11, + color="#888888", + ) + + def _section_sick_budget(self, parent: tk.Widget, sick: SickBudgetStatus) -> None: + """Render rolling sick-day budget usage.""" + del parent + self._label("Sick Budget", font_size=16, pady=8) + 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}", + font_size=12, + color="#ff4444" if sick.exhausted else "#cccccc", + ) + + def _section_manual_workout_budget( + self, parent: tk.Widget, manual: ManualWorkoutBudgetStatus + ) -> None: + """Render rolling manual-workout budget usage.""" + del parent + self._label("Manual Workout Budget", font_size=16, pady=8) + self._text( + f"{manual.used_7d}/{manual.budget_7d} week · " + f"{manual.used_30d}/{manual.budget_30d} month", + font_size=12, + color="#ff4444" if manual.exhausted else "#cccccc", + ) + + def _section_shutdown( + self, parent: tk.Widget, shutdown: ShutdownProjection + ) -> None: + """Render tonight's live config, rest-of-week, and next-week preview.""" + del parent + self._label("Shutdown Time", font_size=16, pady=8) + if shutdown.tonight is not None: + mon_wed_hour, thu_sun_hour, _morning = shutdown.tonight + self._text( + f"Live config — Mon-Wed {mon_wed_hour:02d}:00, " + f"Thu-Sun {thu_sun_hour:02d}:00", + font_size=12, + ) + else: + self._text( + "Live shutdown config unavailable.", font_size=12, color="#ff8844" + ) + rest_line = ", ".join( + f"{d.label} {d.hour:02d}:00" for d in shutdown.rest_of_week + ) + self._text(f"Rest of week: {rest_line}", font_size=10, color="#cccccc") + next_line = ", ".join( + f"{d.label} {d.hour:02d}:00" for d in shutdown.next_week_preview + ) + self._text( + f"Next week (speculative): {next_line}", font_size=10, color="#888888" + ) + self._text(shutdown.explanation, font_size=10, color="#666666") + + def _on_refresh_clicked(self) -> None: + """Clear any stale phone-check result and re-gather the snapshot.""" + self._phone_check_result = None + self.on_refresh() + + def _on_check_phone_clicked(self) -> None: + """Submit a background phone-verification check and start polling it.""" + self._phone_check_result = None + verifier = self.verifier_factory(self.log_file) + executor = ThreadPoolExecutor(max_workers=1) + self._phone_future = executor.submit(verifier._verify_phone_workout) + executor.shutdown(wait=False) + self._poll_phone_check() + + def _poll_phone_check(self) -> None: + """Poll the background phone-check future until it resolves.""" + if self._phone_future is not None and self._phone_future.done(): + status, message = self._phone_future.result() + self._on_phone_check_result(status, message) + else: + self.root.after(500, self._poll_phone_check) + + def _on_phone_check_result(self, status: str, message: str) -> None: + """Display the phone-check result. Never logs — display only.""" + self._phone_check_result = (status, message) + self.render(gather_status()) + + def _on_manual_workout_saved(self, entry: dict) -> None: + """Persist the manual-workout entry via a bare verifier, then refresh. + + Unlike the phone check, this one deliberate path does write to + ``workout_log.json`` — the user just explicitly filled out and + 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 + applied twice on the same day). + """ + verifier = self.verifier_factory(self.log_file) + verifier.workout_data = entry + credit = verifier._apply_workout_credit() + lines = [f"Manual workout logged: {entry.get('source', '')}"] + if credit.already_counted_today: + lines.append( + "(Today already had a counted workout — no extra credit applied.)" + ) + else: + if credit.shutdown_adjusted: + lines.append("Shutdown time +2h later!") + if credit.extra_bonus_delta > 0: + extra_n = credit.weekly_count - _WEEKLY_WORKOUT_MINIMUM + lines.append( + f"Extra workout #{extra_n}! +{credit.extra_bonus_delta}h tonight" + ) + if credit.new_debt is not None: + lines.append(f"Workout debt: {credit.new_debt}") + self._manual_workout_saved_message = "\n".join(lines) + self.render(gather_status()) + + def _on_manual_workout_cancelled(self) -> None: + """Return to the main status view without saving anything.""" + self.render(gather_status()) + + +def _compliance_state_word(snapshot: StatusSnapshot) -> str: + """One-word compliance state for the tray icon: ``ok`` / ``warn`` / ``lock``. + + ``lock`` — the lock would fire right now (no skip condition applies). + ``warn`` — not locked, but this week's minimum isn't met yet. + ``ok`` — not locked and the weekly minimum is already met. + """ + if snapshot.lock_explanation.fired: + return "lock" + if snapshot.week.remaining > 0: + return "warn" + return "ok" + + +def main(argv: list[str] | None = None) -> None: + """Entry point: ``--summary``/``--state`` print one line; else opens the window.""" + args = sys.argv[1:] if argv is None else argv + if "--summary" in args: + print(format_summary_line(gather_status())) + return + if "--state" in args: + print(_compliance_state_word(gather_status())) + return + + root = tk.Tk() + root.title("Workout Status") + root.configure(bg="#1a1a1a") + root.minsize(560, 200) + + def refresh() -> None: + window.render(gather_status()) + + window = StatusWindow(root, gather_status(), on_refresh=refresh) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/screen_locker/tests/_status_view_helpers.py b/screen_locker/tests/_status_view_helpers.py new file mode 100644 index 0000000..d3d814c --- /dev/null +++ b/screen_locker/tests/_status_view_helpers.py @@ -0,0 +1,161 @@ +"""Shared StatusSnapshot-building test helpers for test_status_view*.py. + +Not a test module itself (no test_/\u200b*_test naming) — pytest's collector +(``python_files`` in pyproject.toml) never picks this up as a test file. +""" + +from __future__ import annotations + +import dataclasses +from unittest.mock import MagicMock + +from screen_locker._compliance_state import ( + AutoUpgradeOpportunity, + LockExplanation, + PredicateResult, +) +from screen_locker._status_data import ( + DayStatus, + ManualWorkoutBudgetStatus, + ShutdownProjection, + ShutdownProjectionDay, + SickBudgetStatus, + StatusSnapshot, + WeeklySummary, +) +from screen_locker.status_view import StatusWindow + +_WEEKDAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + + +def _day( + *, + date: str = "2024-01-05", + label: str = "Fri Jan 05", + entry_type: str | None = None, + source: str = "", + counted: bool = False, + is_sick_day: bool = False, +) -> DayStatus: + return DayStatus( + date=date, + label=label, + entry_type=entry_type, + source=source, + counted=counted, + is_sick_day=is_sick_day, + ) + + +def _week( + *, + days: tuple[DayStatus, ...] | None = None, + counted_count: int = 0, + minimum: int = 4, + remaining: int = 4, + extra: int = 0, +) -> WeeklySummary: + return WeeklySummary( + days=days or (_day(),), + counted_count=counted_count, + minimum=minimum, + remaining=remaining, + extra=extra, + ) + + +def _lock_explanation( + *, + fired: bool = False, + stage: str = "already_logged", + reason: str = "Workout already logged today — lock skipped.", + auto_upgrade: AutoUpgradeOpportunity | None = None, + heat_skip_evaluated: bool = False, +) -> LockExplanation: + return LockExplanation( + fired=fired, + stage=stage, + reason=reason, + trace=(PredicateResult("already_logged", not fired, reason),), + auto_upgrade=auto_upgrade + or AutoUpgradeOpportunity(would_attempt=False, via="none", reason="none"), + heat_skip_evaluated=heat_skip_evaluated, + ) + + +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: + return ManualWorkoutBudgetStatus( + used_7d=used_7d, + budget_7d=2, + used_30d=0, + budget_30d=5, + exhausted=exhausted, + ) + + +def _shutdown( + *, tonight: tuple[int, int, int] | None = (21, 21, 5) +) -> ShutdownProjection: + rest = tuple( + ShutdownProjectionDay(label=lbl, hour=21, speculative=False) + for lbl in _WEEKDAY_LABELS + ) + preview = tuple( + ShutdownProjectionDay(label=lbl, hour=21, speculative=True) + for lbl in _WEEKDAY_LABELS + ) + return ShutdownProjection( + tonight=tonight, + rest_of_week=rest, + next_week_preview=preview, + explanation="two separate systems", + ) + + +def _snapshot(**overrides: object) -> StatusSnapshot: + default = StatusSnapshot( + today=_day(), + week=_week(), + bonus_hours_this_week=0, + streak=0, + 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", + ) + return dataclasses.replace(default, **overrides) + + +def _texts(mock_tk: MagicMock) -> list[str]: + """All text/label strings passed to any tk.Label call, in call order.""" + return [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list] + + +def _button_texts(mock_tk: MagicMock) -> set[str]: + return {c.kwargs.get("text") for c in mock_tk.Button.call_args_list} + + +def _make_window( + mock_tk: MagicMock, snapshot: StatusSnapshot, **kwargs: object +) -> StatusWindow: + root = MagicMock() + return StatusWindow( + root, snapshot, on_refresh=kwargs.pop("on_refresh", MagicMock()), **kwargs + ) diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 6588e1d..7dd8d1e 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -1,12 +1,11 @@ """Shared fixtures and helpers for screen_locker tests. Safety: - ``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk`` - module reference inside ``screen_lock`` with a MagicMock, replaces - ``GateRoot`` with a callable returning that same mock root, and stubs - ``sys.exit``. This makes it physically impossible for any test to - create a real Tk root window, go fullscreen, or grab input — even if - the test forgets to request the explicit ``mock_tk`` fixture. + ``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk`` module + reference inside ``screen_lock`` with a MagicMock, replaces ``GateRoot`` + with a callable returning that same mock root, and stubs ``sys.exit`` — no + test can create a real Tk root, go fullscreen, or grab input, even one + that forgets to request the explicit ``mock_tk`` fixture. """ from __future__ import annotations @@ -36,8 +35,11 @@ if TYPE_CHECKING: _TK_MODULES = ( "screen_locker.screen_lock", "screen_locker._sick_dialog", + "screen_locker._manual_workout_dialog", "screen_locker._ui_widgets", "screen_locker._window_setup", + "screen_locker.status_view", + "screen_locker._heat_skip", ) _VT_SHUTIL = "gatelock._vt.shutil" _VT_SUBPROCESS = "gatelock._vt.subprocess" diff --git a/screen_locker/tests/test_compliance_state.py b/screen_locker/tests/test_compliance_state.py new file mode 100644 index 0000000..5e9844c --- /dev/null +++ b/screen_locker/tests/test_compliance_state.py @@ -0,0 +1,275 @@ +"""Tests for the pure, read-only predicates in _compliance_state.py.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from screen_locker import _compliance_state +from screen_locker._compliance_state import ( + AutoUpgradeOpportunity, + describe_auto_upgrade_opportunity, + has_logged_today, + is_early_bird_pending, + is_scheduled_skip_today, + is_sick_day_today, +) +from screen_locker._sick_tracker import SickHistory + +if TYPE_CHECKING: + from pathlib import Path + + +def _today() -> str: + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + + +class TestIsScheduledSkipToday: + """Mirrors test_scheduled_skip.py's coverage, for the standalone function.""" + + def test_missing_file(self, tmp_path: Path) -> None: + assert is_scheduled_skip_today(tmp_path / "skips.json") is False + + def test_today_listed(self, tmp_path: Path) -> None: + skip_file = tmp_path / "skips.json" + skip_file.write_text(json.dumps([_today()])) + assert is_scheduled_skip_today(skip_file) is True + + def test_today_not_listed(self, tmp_path: Path) -> None: + skip_file = tmp_path / "skips.json" + skip_file.write_text(json.dumps(["1999-01-01"])) + assert is_scheduled_skip_today(skip_file) is False + + def test_corrupt_json(self, tmp_path: Path) -> None: + skip_file = tmp_path / "skips.json" + skip_file.write_text("{bad}") + assert is_scheduled_skip_today(skip_file) is False + + def test_explicit_today_override(self, tmp_path: Path) -> None: + skip_file = tmp_path / "skips.json" + skip_file.write_text(json.dumps(["2020-05-05"])) + assert is_scheduled_skip_today(skip_file, today="2020-05-05") is True + + +class TestHasLoggedToday: + """Mirrors test_init_and_log.py's HMAC branches, for the standalone function.""" + + def test_missing_file(self, tmp_path: Path) -> None: + assert has_logged_today(tmp_path / "log.json") is False + + def test_corrupt_json(self, tmp_path: Path) -> None: + log_file = tmp_path / "log.json" + log_file.write_text("{bad}") + assert has_logged_today(log_file) is False + + def test_no_entry_for_today(self, tmp_path: Path) -> None: + log_file = tmp_path / "log.json" + log_file.write_text(json.dumps({"1999-01-01": {}})) + assert has_logged_today(log_file) is False + + def test_valid_hmac(self, tmp_path: Path) -> None: + log_file = tmp_path / "log.json" + log_file.write_text(json.dumps({_today(): {"hmac": "sig"}})) + with patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=True + ): + assert has_logged_today(log_file) is True + + def test_unsigned_accepted_when_key_unavailable(self, tmp_path: Path) -> None: + log_file = tmp_path / "log.json" + log_file.write_text(json.dumps({_today(): {}})) + with ( + patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=False + ), + patch( + "screen_locker._compliance_state.compute_entry_hmac", return_value=None + ), + ): + assert has_logged_today(log_file) is True + + def test_rejected_when_key_available(self, tmp_path: Path) -> None: + log_file = tmp_path / "log.json" + log_file.write_text(json.dumps({_today(): {}})) + with ( + patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=False + ), + patch( + "screen_locker._compliance_state.compute_entry_hmac", return_value="sig" + ), + ): + assert has_logged_today(log_file) is False + + def test_rejected_when_signed_but_invalid(self, tmp_path: Path) -> None: + log_file = tmp_path / "log.json" + log_file.write_text(json.dumps({_today(): {"hmac": "tampered"}})) + with ( + patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=False + ), + patch( + "screen_locker._compliance_state.compute_entry_hmac", return_value=None + ), + ): + assert has_logged_today(log_file) is False + + +class TestIsEarlyBirdPending: + """Mirrors test_early_bird.py's HMAC branches, for the standalone function.""" + + def test_missing_file(self, tmp_path: Path) -> None: + assert is_early_bird_pending(tmp_path / "pending.json") is False + + def test_corrupt_json(self, tmp_path: Path) -> None: + pending_file = tmp_path / "pending.json" + pending_file.write_text("{bad}") + assert is_early_bird_pending(pending_file) is False + + def test_not_a_dict(self, tmp_path: Path) -> None: + pending_file = tmp_path / "pending.json" + pending_file.write_text(json.dumps(["not", "a", "dict"])) + assert is_early_bird_pending(pending_file) is False + + def test_stale_date(self, tmp_path: Path) -> None: + pending_file = tmp_path / "pending.json" + pending_file.write_text(json.dumps({"date": "2000-01-01", "hmac": "sig"})) + assert is_early_bird_pending(pending_file) is False + + def test_valid_hmac(self, tmp_path: Path) -> None: + pending_file = tmp_path / "pending.json" + pending_file.write_text(json.dumps({"date": _today(), "hmac": "sig"})) + with patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=True + ): + assert is_early_bird_pending(pending_file) is True + + def test_unsigned_accepted_when_key_unavailable(self, tmp_path: Path) -> None: + pending_file = tmp_path / "pending.json" + pending_file.write_text(json.dumps({"date": _today()})) + with ( + patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=False + ), + patch( + "screen_locker._compliance_state.compute_entry_hmac", return_value=None + ), + ): + assert is_early_bird_pending(pending_file) is True + + def test_rejected_when_key_available(self, tmp_path: Path) -> None: + pending_file = tmp_path / "pending.json" + pending_file.write_text(json.dumps({"date": _today()})) + with ( + patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=False + ), + patch( + "screen_locker._compliance_state.compute_entry_hmac", return_value="sig" + ), + ): + assert is_early_bird_pending(pending_file) is False + + def test_rejected_when_signed_but_invalid(self, tmp_path: Path) -> None: + pending_file = tmp_path / "pending.json" + pending_file.write_text(json.dumps({"date": _today(), "hmac": "bad"})) + with ( + patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=False + ), + patch( + "screen_locker._compliance_state.compute_entry_hmac", return_value=None + ), + ): + assert is_early_bird_pending(pending_file) is False + + +class TestIsSickDayToday: + """Thin wrapper around _sick_tracker.is_sick_day.""" + + def test_true_when_listed(self) -> None: + history = SickHistory(sick_days=[_today()]) + assert is_sick_day_today(history) is True + + def test_false_when_not_listed(self) -> None: + history = SickHistory(sick_days=["1999-01-01"]) + assert is_sick_day_today(history) is False + + def test_explicit_today_override(self) -> None: + history = SickHistory(sick_days=["2020-05-05"]) + assert is_sick_day_today(history, today="2020-05-05") is True + + +class TestEarlyBirdWindowOpen: + """Direct tests for the module-private, deliberately independent reimplementation.""" + + def test_before_window(self) -> None: + assert ( + _compliance_state._early_bird_window_open(extended=False, local_minutes=299) + is False + ) + + def test_at_start(self) -> None: + assert ( + _compliance_state._early_bird_window_open(extended=False, local_minutes=300) + is True + ) + + def test_before_end(self) -> None: + assert ( + _compliance_state._early_bird_window_open(extended=False, local_minutes=509) + is True + ) + + def test_at_end_exclusive(self) -> None: + assert ( + _compliance_state._early_bird_window_open(extended=False, local_minutes=510) + is False + ) + + def test_extended_before_end(self) -> None: + assert ( + _compliance_state._early_bird_window_open(extended=True, local_minutes=539) + is True + ) + + def test_extended_at_end_exclusive(self) -> None: + assert ( + _compliance_state._early_bird_window_open(extended=True, local_minutes=540) + is False + ) + + +class TestDescribeAutoUpgradeOpportunity: + """The 3 possible outcomes: expired early-bird, sick day, or none.""" + + def test_expired_early_bird(self) -> None: + result = describe_auto_upgrade_opportunity( + early_bird_pending=True, early_bird_window_open=False, is_sick_day=False + ) + assert result == AutoUpgradeOpportunity( + would_attempt=True, via="early_bird_expired", reason=result.reason + ) + + def test_sick_day(self) -> None: + result = describe_auto_upgrade_opportunity( + early_bird_pending=False, early_bird_window_open=False, is_sick_day=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" + + def test_none(self) -> None: + result = describe_auto_upgrade_opportunity( + early_bird_pending=False, early_bird_window_open=False, is_sick_day=False + ) + assert result.would_attempt is False + assert result.via == "none" diff --git a/screen_locker/tests/test_compliance_state_part2.py b/screen_locker/tests/test_compliance_state_part2.py new file mode 100644 index 0000000..44b1b20 --- /dev/null +++ b/screen_locker/tests/test_compliance_state_part2.py @@ -0,0 +1,203 @@ +"""Tests for _compliance_state.explain_lock_decision's full branch trace.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from screen_locker._compliance_state import explain_lock_decision +from screen_locker._sick_tracker import SickHistory + +if TYPE_CHECKING: + from pathlib import Path + + +def _today() -> str: + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + + +class TestExplainLockDecision: + """Full branch trace of the read-only lock-decision chain.""" + + def _files(self, tmp_path: Path) -> dict[str, Path]: + return { + "log_file": tmp_path / "workout_log.json", + "scheduled_skips_file": tmp_path / "scheduled_skips.json", + "early_bird_pending_file": tmp_path / "early_bird_pending.json", + } + + def test_scheduled_skip_short_circuits(self, tmp_path: Path) -> None: + files = self._files(tmp_path) + files["scheduled_skips_file"].write_text(json.dumps([_today()])) + result = explain_lock_decision( + **files, + sick_history=SickHistory(), + extended_early_bird=False, + weekly_minimum_met=False, + relaxed_day=False, + ) + assert result.fired is False + assert result.stage == "scheduled_skip" + assert result.trace[0].fired is True + + def test_early_bird_window_still_open_skips(self, tmp_path: Path) -> None: + files = self._files(tmp_path) + files["early_bird_pending_file"].write_text( + json.dumps({"date": _today(), "hmac": "sig"}) + ) + now = datetime.now(tz=timezone.utc).astimezone().replace(hour=6, minute=0) + with patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=True + ): + result = explain_lock_decision( + **files, + sick_history=SickHistory(), + extended_early_bird=False, + weekly_minimum_met=False, + relaxed_day=False, + now=now, + ) + assert result.fired is False + assert result.stage == "early_bird_window_open" + + def test_expired_early_bird_falls_through_to_full_lock( + self, tmp_path: Path + ) -> None: + files = self._files(tmp_path) + files["early_bird_pending_file"].write_text( + json.dumps({"date": _today(), "hmac": "sig"}) + ) + now = datetime.now(tz=timezone.utc).astimezone().replace(hour=10, minute=0) + with patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=True + ): + result = explain_lock_decision( + **files, + sick_history=SickHistory(), + extended_early_bird=False, + weekly_minimum_met=False, + relaxed_day=False, + now=now, + ) + assert result.fired is True + assert result.stage == "full_lock_pending_heat_check" + assert result.auto_upgrade.via == "early_bird_expired" + expired_step = next( + t for t in result.trace if t.name == "early_bird_pending_expired" + ) + assert expired_step.fired is True + + def test_sick_day_skips(self, tmp_path: Path) -> None: + files = self._files(tmp_path) + result = explain_lock_decision( + **files, + sick_history=SickHistory(sick_days=[_today()]), + extended_early_bird=False, + weekly_minimum_met=False, + relaxed_day=False, + now=datetime.now(tz=timezone.utc).astimezone().replace(hour=12, minute=0), + ) + assert result.fired is False + assert result.stage == "sick_day" + + def test_already_logged_skips(self, tmp_path: Path) -> None: + files = self._files(tmp_path) + files["log_file"].write_text(json.dumps({_today(): {"hmac": "sig"}})) + with patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=True + ): + result = explain_lock_decision( + **files, + sick_history=SickHistory(), + extended_early_bird=False, + weekly_minimum_met=False, + relaxed_day=False, + now=datetime.now(tz=timezone.utc) + .astimezone() + .replace(hour=12, minute=0), + ) + assert result.fired is False + assert result.stage == "already_logged" + + def test_wake_alarm_skip_skips(self, tmp_path: Path) -> None: + files = self._files(tmp_path) + result = explain_lock_decision( + **files, + sick_history=SickHistory(), + extended_early_bird=False, + weekly_minimum_met=False, + relaxed_day=False, + wake_skip=True, + now=datetime.now(tz=timezone.utc).astimezone().replace(hour=12, minute=0), + ) + assert result.fired is False + assert result.stage == "wake_alarm_skip" + + def test_fresh_early_bird_time_skips(self, tmp_path: Path) -> None: + files = self._files(tmp_path) + now = datetime.now(tz=timezone.utc).astimezone().replace(hour=6, minute=0) + result = explain_lock_decision( + **files, + sick_history=SickHistory(), + extended_early_bird=False, + weekly_minimum_met=False, + relaxed_day=False, + now=now, + ) + assert result.fired is False + assert result.stage == "early_bird_time_fresh" + + def test_relaxed_day_skips(self, tmp_path: Path) -> None: + files = self._files(tmp_path) + result = explain_lock_decision( + **files, + sick_history=SickHistory(), + extended_early_bird=False, + weekly_minimum_met=False, + relaxed_day=True, + now=datetime.now(tz=timezone.utc).astimezone().replace(hour=12, minute=0), + ) + assert result.fired is False + assert result.stage == "relaxed_day" + + def test_weekly_minimum_met_skips(self, tmp_path: Path) -> None: + files = self._files(tmp_path) + result = explain_lock_decision( + **files, + sick_history=SickHistory(), + extended_early_bird=False, + weekly_minimum_met=True, + relaxed_day=False, + now=datetime.now(tz=timezone.utc).astimezone().replace(hour=12, minute=0), + ) + assert result.fired is False + assert result.stage == "weekly_minimum_met" + + def test_full_lock_when_nothing_applies(self, tmp_path: Path) -> None: + files = self._files(tmp_path) + result = explain_lock_decision( + **files, + sick_history=SickHistory(), + extended_early_bird=False, + weekly_minimum_met=False, + relaxed_day=False, + now=datetime.now(tz=timezone.utc).astimezone().replace(hour=12, minute=0), + ) + assert result.fired is True + assert result.stage == "full_lock_pending_heat_check" + assert result.auto_upgrade.via == "none" + assert result.heat_skip_evaluated is False + + def test_defaults_now_to_current_time(self, tmp_path: Path) -> None: + """Covers the ``now is None`` branch — just needs to not raise.""" + files = self._files(tmp_path) + result = explain_lock_decision( + **files, + sick_history=SickHistory(), + extended_early_bird=False, + weekly_minimum_met=False, + relaxed_day=False, + ) + assert isinstance(result.fired, bool) diff --git a/screen_locker/tests/test_early_bird.py b/screen_locker/tests/test_early_bird.py index 893e77b..346e803 100644 --- a/screen_locker/tests/test_early_bird.py +++ b/screen_locker/tests/test_early_bird.py @@ -220,8 +220,12 @@ class TestIsEarlyBirdPending: pending_file.write_text(json.dumps({"date": today, "hmac": "bad"})) with ( patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file), - patch("screen_locker._early_bird.verify_entry_hmac", return_value=False), - patch("screen_locker._early_bird.compute_entry_hmac", return_value="sig"), + patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=False + ), + patch( + "screen_locker._compliance_state.compute_entry_hmac", return_value="sig" + ), ): assert locker._is_early_bird_pending() is False @@ -238,7 +242,9 @@ class TestIsEarlyBirdPending: pending_file.write_text(json.dumps({"date": today, "hmac": "sig"})) with ( patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file), - patch("screen_locker._early_bird.verify_entry_hmac", return_value=True), + patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=True + ), ): assert locker._is_early_bird_pending() is True @@ -255,8 +261,12 @@ class TestIsEarlyBirdPending: pending_file.write_text(json.dumps({"date": today})) with ( patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file), - patch("screen_locker._early_bird.verify_entry_hmac", return_value=False), - patch("screen_locker._early_bird.compute_entry_hmac", return_value=None), + patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=False + ), + patch( + "screen_locker._compliance_state.compute_entry_hmac", return_value=None + ), ): assert locker._is_early_bird_pending() is True diff --git a/screen_locker/tests/test_extra_benefits.py b/screen_locker/tests/test_extra_benefits.py index b679f05..94a38e2 100644 --- a/screen_locker/tests/test_extra_benefits.py +++ b/screen_locker/tests/test_extra_benefits.py @@ -12,6 +12,7 @@ from screen_locker._extra_benefits import ( _save_state, current_streak, has_extended_early_bird, + preview_bonus_if_week_ended_now, process_week_transition, weekly_shutdown_bonus_hours, ) @@ -263,6 +264,26 @@ class TestWeeklyShutdownBonusHours: assert weekly_shutdown_bonus_hours(f) == 0 +class TestPreviewBonusIfWeekEndedNow: + """Tests for preview_bonus_if_week_ended_now — pure threshold math.""" + + def test_below_threshold_returns_zero(self) -> None: + """< 5 workouts: no streak increment, no bonus (line 67 branch).""" + assert preview_bonus_if_week_ended_now(3, current_streak=2) == (0, 0) + + def test_at_threshold_awards_streak_and_bonus(self) -> None: + """Exactly 5 workouts: streak+1, bonus = count - 4.""" + assert preview_bonus_if_week_ended_now(5, current_streak=0) == (1, 1) + + def test_above_threshold_awards_larger_bonus(self) -> None: + """7 workouts: bonus = 7 - 4 = 3.""" + assert preview_bonus_if_week_ended_now(7, current_streak=0) == (1, 3) + + def test_milestone_streak_adds_extra_hour(self) -> None: + """Streak reaching a multiple of 4 adds +1h on top of the base bonus.""" + assert preview_bonus_if_week_ended_now(5, current_streak=3) == (4, 2) + + class TestHasExtendedEarlyBird: """Tests for has_extended_early_bird.""" @@ -280,3 +301,11 @@ class TestHasExtendedEarlyBird: f = tmp_path / "state.json" f.write_text(json.dumps({"extended_early_bird_iso_weeks": [current_week]})) assert has_extended_early_bird(f) is True + + def test_explicit_today_override(self, tmp_path: Path) -> None: + """An explicit `today` is used instead of the real wall clock.""" + f = tmp_path / "state.json" + f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2024-W01"]})) + fixed_today = datetime(2024, 1, 5, tzinfo=timezone.utc) + assert has_extended_early_bird(f, today=fixed_today) is True + assert has_extended_early_bird(f) is False diff --git a/screen_locker/tests/test_heat_skip.py b/screen_locker/tests/test_heat_skip.py new file mode 100644 index 0000000..6f6fdf8 --- /dev/null +++ b/screen_locker/tests/test_heat_skip.py @@ -0,0 +1,66 @@ +"""Tests for the heat-skip confirmation dialog and log entry.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from screen_locker._heat_skip import HeatSkipMixin + + +class _HeatSkipHost(HeatSkipMixin): + """Minimal host exposing just what HeatSkipMixin needs from ScreenLocker.""" + + def __init__(self) -> None: + self.workout_data: dict[str, str] = {} + self.save_workout_log = MagicMock() + + +def _button_command(mock_tk: MagicMock, text: str): + for call in mock_tk.Button.call_args_list: + if call.kwargs.get("text") == text: + return call.kwargs["command"] + msg = f"No button with text {text!r} was created" + raise AssertionError(msg) + + +class TestShowHeatSkipDialog: + """_show_heat_skip_dialog blocks on mainloop() until a button fires destroy().""" + + def test_returns_true_when_skip_clicked(self, mock_tk: MagicMock) -> None: + host = _HeatSkipHost() + + def _simulate_click() -> None: + _button_command(mock_tk, "Skip workout")() + + mock_tk.Tk.return_value.mainloop.side_effect = _simulate_click + assert host._show_heat_skip_dialog(35.0) is True + mock_tk.Tk.return_value.destroy.assert_called_once() + + def test_returns_false_when_declined(self, mock_tk: MagicMock) -> None: + host = _HeatSkipHost() + + def _simulate_click() -> None: + _button_command(mock_tk, "No, I'll workout")() + + mock_tk.Tk.return_value.mainloop.side_effect = _simulate_click + assert host._show_heat_skip_dialog(35.0) is False + + def test_dialog_shows_temperature_and_threshold(self, mock_tk: MagicMock) -> None: + host = _HeatSkipHost() + mock_tk.Tk.return_value.mainloop.side_effect = None + host._show_heat_skip_dialog(36.4) + texts = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list] + assert any("36°C" in t for t in texts) + assert any("threshold" in t for t in texts) + + +class TestSaveHeatSkipLog: + """_save_heat_skip_log builds workout_data and delegates to save_workout_log.""" + + def test_saves_expected_workout_data(self) -> None: + host = _HeatSkipHost() + host._save_heat_skip_log(34.6) + assert host.workout_data["type"] == "heat_skip" + assert host.workout_data["temperature_celsius"] == "35" + assert host.workout_data["city"] + host.save_workout_log.assert_called_once() diff --git a/screen_locker/tests/test_init_and_log.py b/screen_locker/tests/test_init_and_log.py index d1f4710..3a6fa39 100644 --- a/screen_locker/tests/test_init_and_log.py +++ b/screen_locker/tests/test_init_and_log.py @@ -128,7 +128,7 @@ class TestHasLoggedToday: locker = create_locker(mock_tk, tmp_path) locker.log_file = log_file with patch( - "screen_locker._log_mixin.verify_entry_hmac", + "screen_locker._compliance_state.verify_entry_hmac", return_value=True, ): assert locker.has_logged_today() is True @@ -149,7 +149,7 @@ class TestHasLoggedToday: locker = create_locker(mock_tk, tmp_path) locker.log_file = log_file with patch( - "screen_locker._log_mixin.verify_entry_hmac", + "screen_locker._compliance_state.verify_entry_hmac", return_value=False, ): assert locker.has_logged_today() is False @@ -171,11 +171,11 @@ class TestHasLoggedToday: locker.log_file = log_file with ( patch( - "screen_locker._log_mixin.verify_entry_hmac", + "screen_locker._compliance_state.verify_entry_hmac", return_value=False, ), patch( - "screen_locker._log_mixin.compute_entry_hmac", + "screen_locker._compliance_state.compute_entry_hmac", return_value=None, ), ): @@ -198,11 +198,11 @@ class TestHasLoggedToday: locker.log_file = log_file with ( patch( - "screen_locker._log_mixin.verify_entry_hmac", + "screen_locker._compliance_state.verify_entry_hmac", return_value=False, ), patch( - "screen_locker._log_mixin.compute_entry_hmac", + "screen_locker._compliance_state.compute_entry_hmac", return_value="some-signature", ), ): diff --git a/screen_locker/tests/test_manual_workout.py b/screen_locker/tests/test_manual_workout.py new file mode 100644 index 0000000..52c229a --- /dev/null +++ b/screen_locker/tests/test_manual_workout.py @@ -0,0 +1,300 @@ +"""Tests for the manual-workout pure-logic module.""" +# pylint: disable=protected-access + +from __future__ import annotations + +import dataclasses +import json +from typing import TYPE_CHECKING + +import pytest + +from screen_locker._constants import ( + MANUAL_WORKOUT_BUDGET_PER_7_DAYS, + MANUAL_WORKOUT_BUDGET_PER_30_DAYS, + MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS, + MANUAL_WORKOUT_MIN_DURATION_MINUTES, + MANUAL_WORKOUT_REFLECTION_MIN_CHARS, +) +from screen_locker._manual_workout import ( + SPORT_OTHER, + SPORT_TABLE_TENNIS, + ManualWorkoutDraft, + budget_summary, + build_entry, + count_in_window, + is_budget_exhausted, + validate_manual_workout, +) + +if TYPE_CHECKING: + from pathlib import Path + +_TODAY = "2026-07-05" + + +def _write_logs(log_file: Path, entries: dict[str, str]) -> None: + """Write a workout_log.json with one entry per {date: type} pair.""" + logs = { + date: {"timestamp": f"{date}T12:00:00+00:00", "workout_data": {"type": wtype}} + for date, wtype in entries.items() + } + log_file.write_text(json.dumps(logs)) + + +class TestCountInWindow: + """Tests for count_in_window.""" + + def test_counts_only_manual_workout_type(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + _write_logs( + log_file, + { + "2026-07-04": "manual_workout", + "2026-07-03": "phone_verified", + "2026-07-02": "manual_workout", + }, + ) + assert count_in_window(log_file, 7, today=_TODAY) == 2 + + def test_respects_window_cutoff(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + _write_logs( + log_file, + { + "2026-07-04": "manual_workout", # 1 day ago: in 7d + "2026-06-28": "manual_workout", # 7 days ago: NOT in 7d (exclusive) + "2026-06-20": "manual_workout", # 15 days ago: in 30d, not 7d + }, + ) + assert count_in_window(log_file, 7, today=_TODAY) == 1 + assert count_in_window(log_file, 30, today=_TODAY) == 3 + + def test_missing_file_returns_zero(self, tmp_path: Path) -> None: + log_file = tmp_path / "does_not_exist.json" + assert count_in_window(log_file, 7, today=_TODAY) == 0 + + def test_corrupt_json_returns_zero(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + log_file.write_text("not json") + assert count_in_window(log_file, 7, today=_TODAY) == 0 + + def test_skips_invalid_date_keys(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + log_file.write_text( + json.dumps( + { + "not-a-date": {"workout_data": {"type": "manual_workout"}}, + "2026-07-04": {"workout_data": {"type": "manual_workout"}}, + } + ) + ) + assert count_in_window(log_file, 7, today=_TODAY) == 1 + + def test_returns_zero_when_today_invalid(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + _write_logs(log_file, {"2026-07-04": "manual_workout"}) + assert count_in_window(log_file, 7, today="bogus") == 0 + + def test_uses_today_default_when_none(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + assert count_in_window(log_file, 7) == 0 + + +class TestIsBudgetExhausted: + """Tests for is_budget_exhausted.""" + + def test_false_when_under_budget(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + assert is_budget_exhausted(log_file, today=_TODAY) is False + + def test_true_when_weekly_exhausted(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + entries = { + f"2026-07-0{4 - i}": "manual_workout" + for i in range(MANUAL_WORKOUT_BUDGET_PER_7_DAYS) + } + _write_logs(log_file, entries) + assert is_budget_exhausted(log_file, today=_TODAY) is True + + def test_true_when_monthly_exhausted(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + # All strictly after the 30d cutoff (2026-06-05) but at/before the + # 7d cutoff (2026-06-28), so only the 30d window sees them. + dates = ["2026-06-08", "2026-06-13", "2026-06-18", "2026-06-23", "2026-06-28"] + entries = dict.fromkeys( + dates[:MANUAL_WORKOUT_BUDGET_PER_30_DAYS], "manual_workout" + ) + _write_logs(log_file, entries) + assert count_in_window(log_file, 7, today=_TODAY) == 0 + assert is_budget_exhausted(log_file, today=_TODAY) is True + + +class TestBudgetSummary: + """Tests for budget_summary.""" + + def test_renders_both_windows(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + _write_logs(log_file, {"2026-07-04": "manual_workout"}) + summary = budget_summary(log_file, today=_TODAY) + assert "Manual:" in summary + assert "1/" in summary + + +_TT_DEFAULT = ManualWorkoutDraft( + sport=SPORT_TABLE_TENNIS, + start_time="12:00", + end_time="14:00", + location_name="Osrodek Solec", + transport_method="bike", + cost="60 PLN", + rpe=6, + went_well="x" * MANUAL_WORKOUT_REFLECTION_MIN_CHARS, + to_improve="y" * MANUAL_WORKOUT_REFLECTION_MIN_CHARS, + overall_feeling="z" * MANUAL_WORKOUT_REFLECTION_MIN_CHARS, + matches_won=1, + matches_lost=4, + sets_won=2, + sets_lost=9, + racket="pro spin", + balls="nittaku", +) + +_OTHER_DEFAULT = ManualWorkoutDraft( + sport=SPORT_OTHER, + start_time="09:00", + end_time="10:00", + location_name="Squash club", + transport_method="car", + cost="30 PLN", + rpe=5, + went_well="x" * MANUAL_WORKOUT_REFLECTION_MIN_CHARS, + to_improve="y" * MANUAL_WORKOUT_REFLECTION_MIN_CHARS, + overall_feeling="z" * MANUAL_WORKOUT_REFLECTION_MIN_CHARS, + activity_type_other="squash", + activity_details="q" * MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS, +) + + +def _tt_draft(**overrides: object) -> ManualWorkoutDraft: + """Build a valid table-tennis draft, with overrides applied.""" + return dataclasses.replace(_TT_DEFAULT, **overrides) + + +def _other_draft(**overrides: object) -> ManualWorkoutDraft: + """Build a valid "other sport" draft, with overrides applied.""" + return dataclasses.replace(_OTHER_DEFAULT, **overrides) + + +class TestValidateManualWorkout: + """Tests for validate_manual_workout.""" + + def test_valid_table_tennis_returns_none(self) -> None: + assert validate_manual_workout(_tt_draft()) is None + + def test_valid_other_returns_none(self) -> None: + assert validate_manual_workout(_other_draft()) is None + + def test_rejects_unknown_sport(self) -> None: + assert validate_manual_workout(_tt_draft(sport="badminton")) is not None + + @pytest.mark.parametrize( + "field", + ["start_time", "end_time", "location_name", "transport_method", "cost"], + ) + def test_rejects_blank_required_field(self, field: str) -> None: + assert validate_manual_workout(_tt_draft(**{field: " "})) is not None + + def test_rejects_bad_time_format(self) -> None: + assert validate_manual_workout(_tt_draft(start_time="noon")) is not None + + def test_rejects_end_before_start(self) -> None: + assert ( + validate_manual_workout(_tt_draft(start_time="14:00", end_time="12:00")) + is not None + ) + + def test_rejects_too_short_duration(self) -> None: + draft = _tt_draft(start_time="12:00", end_time="12:05") + assert MANUAL_WORKOUT_MIN_DURATION_MINUTES > 5 + assert validate_manual_workout(draft) is not None + + @pytest.mark.parametrize("rpe", [0, 11, -1]) + def test_rejects_rpe_out_of_range(self, rpe: int) -> None: + assert validate_manual_workout(_tt_draft(rpe=rpe)) is not None + + @pytest.mark.parametrize( + "field", ["matches_won", "matches_lost", "sets_won", "sets_lost"] + ) + def test_rejects_negative_table_tennis_counts(self, field: str) -> None: + assert validate_manual_workout(_tt_draft(**{field: -1})) is not None + + def test_rejects_zero_matches_played(self) -> None: + draft = _tt_draft(matches_won=0, matches_lost=0) + assert validate_manual_workout(draft) is not None + + def test_rejects_blank_racket(self) -> None: + assert validate_manual_workout(_tt_draft(racket=" ")) is not None + + def test_rejects_blank_balls(self) -> None: + assert validate_manual_workout(_tt_draft(balls="")) is not None + + def test_rejects_blank_activity_type_other(self) -> None: + assert validate_manual_workout(_other_draft(activity_type_other="")) is not None + + def test_rejects_short_activity_details(self) -> None: + assert ( + validate_manual_workout(_other_draft(activity_details="too short")) + is not None + ) + + @pytest.mark.parametrize("field", ["went_well", "to_improve", "overall_feeling"]) + def test_rejects_short_reflection_field(self, field: str) -> None: + assert validate_manual_workout(_tt_draft(**{field: "short"})) is not None + + +class TestBuildEntry: + """Tests for build_entry.""" + + def test_table_tennis_entry_shape(self) -> None: + entry = build_entry(_tt_draft()) + assert entry["type"] == "manual_workout" + assert entry["sport"] == "table_tennis" + assert entry["activity_type"] == "table tennis" + assert entry["source"] == "table tennis at Osrodek Solec" + assert entry["duration_minutes"] == "120.0" + assert entry["matches_won"] == 1 + assert entry["matches_lost"] == 4 + assert entry["sets_won"] == 2 + assert entry["sets_lost"] == 9 + assert entry["racket"] == "pro spin" + assert entry["balls"] == "nittaku" + assert "activity_details" not in entry + assert "equipment" not in entry + + def test_other_sport_entry_shape(self) -> None: + entry = build_entry(_other_draft()) + assert entry["type"] == "manual_workout" + assert entry["sport"] == "other" + assert entry["activity_type"] == "squash" + assert entry["source"] == "squash at Squash club" + assert entry["activity_details"] == "q" * MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS + assert "matches_won" not in entry + assert "racket" not in entry + + def test_duration_minutes_empty_when_unparsable(self) -> None: + entry = build_entry(_tt_draft(start_time="bogus")) + assert entry["duration_minutes"] == "" + + def test_strips_whitespace_fields(self) -> None: + entry = build_entry(_tt_draft(location_name=" Solec ", racket=" spin ")) + assert entry["location_name"] == "Solec" + assert entry["racket"] == "spin" + + def test_optional_location_maps_link_defaults_empty(self) -> None: + entry = build_entry(_tt_draft()) + assert entry["location_maps_link"] == "" + + def test_pain_or_injury_defaults_to_none(self) -> None: + entry = build_entry(_tt_draft()) + assert entry["pain_or_injury"] == "none" diff --git a/screen_locker/tests/test_manual_workout_dialog.py b/screen_locker/tests/test_manual_workout_dialog.py new file mode 100644 index 0000000..841f90b --- /dev/null +++ b/screen_locker/tests/test_manual_workout_dialog.py @@ -0,0 +1,209 @@ +"""Tests for the manual-workout evidence-form dialog mixin.""" +# pylint: disable=protected-access + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker import _manual_workout +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestShowManualWorkoutForm: + """Tests for _show_manual_workout_form.""" + + def test_renders_form_when_budget_available( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + with patch.object(_manual_workout, "is_budget_exhausted", return_value=False): + locker._show_manual_workout_form() + button_texts = {c.kwargs.get("text") for c in mock_tk.Button.call_args_list} + assert "SUBMIT" in button_texts + assert "BACK" in button_texts + + def test_shows_exhausted_message_and_hides_submit( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + with patch.object(_manual_workout, "is_budget_exhausted", return_value=True): + locker._show_manual_workout_form() + button_texts = {c.kwargs.get("text") for c in mock_tk.Button.call_args_list} + assert "BACK" in button_texts + assert "SUBMIT" not in button_texts + text_calls = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list] + assert any("exhausted" in t for t in text_calls) + + +class TestSportToggle: + """Tests for _on_mw_sport_changed and _current_mw_sport.""" + + def test_selecting_other_shows_other_frame( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker._mw_tt_frame = MagicMock() + locker._mw_other_frame = MagicMock() + locker._mw_sport_var = MagicMock() + locker._on_mw_sport_changed("Other") + locker._mw_tt_frame.pack_forget.assert_called_once() + locker._mw_other_frame.pack.assert_called_once_with(fill="x") + + def test_selecting_table_tennis_shows_tt_frame( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker._mw_tt_frame = MagicMock() + locker._mw_other_frame = MagicMock() + locker._mw_sport_var = MagicMock() + locker._on_mw_sport_changed("Table tennis") + locker._mw_other_frame.pack_forget.assert_called_once() + locker._mw_tt_frame.pack.assert_called_once_with(fill="x") + + def test_unknown_label_defaults_to_table_tennis( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker._mw_tt_frame = MagicMock() + locker._mw_other_frame = MagicMock() + locker._mw_sport_var = MagicMock() + locker._mw_sport_var.get.return_value = "not a real label" + assert locker._current_mw_sport() == _manual_workout.SPORT_TABLE_TENNIS + + +class TestSubmitManualWorkoutForm: + """Tests for _submit_manual_workout_form.""" + + def _setup_locker( + self, + mock_tk: MagicMock, + tmp_path: Path, + *, + sport_label: str = "Table tennis", + fields: dict[str, object] | None = None, + ) -> object: + defaults: dict[str, object] = { + "start_time": "12:00", + "end_time": "14:00", + "location_name": "Osrodek Solec", + "location_maps_link": "", + "transport_method": "bike", + "cost": "60 PLN", + "reservation_phone": "", + "proof_screenshot_path": "", + "techniques_practiced": "", + "warm_up_minutes": "", + "pain_or_injury": "none", + "racket": "pro spin", + "balls": "nittaku", + "activity_type_other": "squash", + "went_well": "x" * 30, + "to_improve": "y" * 30, + "overall_feeling": "z" * 30, + "activity_details": "q" * 45, + } + if fields: + defaults.update(fields) + + locker = create_locker(mock_tk, tmp_path) + locker._mw_sport_var = MagicMock() + locker._mw_sport_var.get.return_value = sport_label + locker._mw_rpe_var = MagicMock() + locker._mw_rpe_var.get.return_value = 6 + + locker._mw_vars = {} + for key in ( + "start_time", + "end_time", + "location_name", + "location_maps_link", + "transport_method", + "cost", + "reservation_phone", + "proof_screenshot_path", + "techniques_practiced", + "warm_up_minutes", + "pain_or_injury", + "racket", + "balls", + "activity_type_other", + "equipment", + ): + var = MagicMock() + var.get.return_value = defaults.get(key, "") + locker._mw_vars[key] = var + + locker._mw_int_vars = {} + for key, default_val in ( + ("matches_won", 1), + ("matches_lost", 4), + ("sets_won", 2), + ("sets_lost", 9), + ): + var = MagicMock() + var.get.return_value = defaults.get(key, default_val) + locker._mw_int_vars[key] = var + + locker._mw_text_widgets = {} + for key in ("went_well", "to_improve", "overall_feeling", "activity_details"): + widget = MagicMock() + widget.get.return_value = defaults[key] + locker._mw_text_widgets[key] = widget + + locker._mw_error_label = MagicMock() + object.__setattr__(locker, "_on_manual_workout_saved", MagicMock()) + return locker + + def test_valid_table_tennis_calls_saved_hook( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = self._setup_locker(mock_tk, tmp_path, sport_label="Table tennis") + locker._submit_manual_workout_form() + locker._on_manual_workout_saved.assert_called_once() + entry = locker._on_manual_workout_saved.call_args.args[0] + assert entry["sport"] == "table_tennis" + assert entry["matches_lost"] == 4 + locker._mw_error_label.config.assert_not_called() + + def test_valid_other_sport_calls_saved_hook( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = self._setup_locker(mock_tk, tmp_path, sport_label="Other") + locker._submit_manual_workout_form() + locker._on_manual_workout_saved.assert_called_once() + entry = locker._on_manual_workout_saved.call_args.args[0] + assert entry["sport"] == "other" + assert entry["activity_type"] == "squash" + + def test_validation_failure_shows_error_and_skips_hook( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = self._setup_locker(mock_tk, tmp_path, fields={"location_name": " "}) + locker._submit_manual_workout_form() + locker._mw_error_label.config.assert_called_once() + locker._on_manual_workout_saved.assert_not_called() + + def test_rpe_value_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._mw_rpe_var.get.side_effect = ValueError("bad") + locker._submit_manual_workout_form() + locker._mw_error_label.config.assert_called_once() + locker._on_manual_workout_saved.assert_not_called() + + def test_int_field_value_error_treated_as_zero( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = self._setup_locker(mock_tk, tmp_path) + locker._mw_int_vars["matches_won"].get.side_effect = ValueError("bad") + locker._mw_int_vars["matches_lost"].get.return_value = 0 + locker._submit_manual_workout_form() + # matches_won=0 (from the ValueError fallback) and matches_lost=0 + # -> "zero matches played" validation error. + locker._mw_error_label.config.assert_called_once() + locker._on_manual_workout_saved.assert_not_called() diff --git a/screen_locker/tests/test_manual_workout_features.py b/screen_locker/tests/test_manual_workout_features.py new file mode 100644 index 0000000..1aa1503 --- /dev/null +++ b/screen_locker/tests/test_manual_workout_features.py @@ -0,0 +1,235 @@ +"""Tests for manual-workout integration in _ui_flows.py and screen_lock.py.""" +# pylint: disable=protected-access + +from __future__ import annotations + +import json +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.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestShowRetryAndManualWorkoutBudget: + """Tests for the "Log Manual Workout" button in _show_retry_and_sick.""" + + def test_shows_button_when_budget_available( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + 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") + button_texts = {c.kwargs.get("text") for c in mock_tk.Button.call_args_list} + assert "Log Manual Workout" in button_texts + + def test_hides_button_when_budget_exhausted( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + 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") + button_texts: set[str] = set() + for call in mock_tk.Button.call_args_list: + button_texts.add(call.kwargs.get("text", "")) + assert "Log Manual Workout" not in button_texts + text_calls = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list] + assert any("Manual-workout budget exhausted" in t for t in text_calls) + + +class TestOnManualWorkoutSaved: + """Tests for UIFlowsMixin._on_manual_workout_saved.""" + + def test_sets_workout_data_and_schedules_unlock( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "unlock_screen", MagicMock()) + object.__setattr__(locker.root, "after", MagicMock()) + entry = {"type": "manual_workout", "source": "table tennis at Solec"} + + locker._on_manual_workout_saved(entry) + + assert locker.workout_data == entry + locker.unlock_screen.assert_not_called() + locker.root.after.assert_called_once_with(1500, locker.unlock_screen) + + +class TestOnManualWorkoutCancelled: + """Tests for UIFlowsMixin._on_manual_workout_cancelled.""" + + def test_returns_to_phone_check( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "_start_phone_check", MagicMock()) + locker._on_manual_workout_cancelled() + locker._start_phone_check.assert_called_once() + + +def _write_today_entry(log_file: Path, today: str, entry_type: str) -> None: + """Write a single workout_log.json entry for *today*.""" + log_file.write_text( + json.dumps( + { + today: { + "timestamp": f"{today}T08:00:00+00:00", + "workout_data": {"type": entry_type}, + } + } + ) + ) + + +class TestWasAlreadyCountedToday: + """Tests for ScreenLocker._was_already_counted_today.""" + + def test_false_when_no_log_file( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + assert locker._was_already_counted_today() is False + + def test_false_when_todays_entry_missing( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker.log_file.write_text( + json.dumps({"2020-01-01": {"workout_data": {"type": "phone_verified"}}}) + ) + assert locker._was_already_counted_today() is False + + def test_true_when_todays_entry_is_counted_type( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + with patch( + "screen_locker._workout_credit.datetime", + ) as mock_dt: + mock_dt.now.return_value.strftime.return_value = "2026-07-05" + _write_today_entry(locker.log_file, "2026-07-05", "manual_workout") + assert locker._was_already_counted_today() is True + + def test_false_when_todays_entry_is_noncounted_type( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + with patch("screen_locker._workout_credit.datetime") as mock_dt: + mock_dt.now.return_value.strftime.return_value = "2026-07-05" + _write_today_entry(locker.log_file, "2026-07-05", "sick_day") + assert locker._was_already_counted_today() is False + + +class TestApplyWorkoutCredit: + """Tests for ScreenLocker._apply_workout_credit.""" + + def test_skips_credit_when_already_counted_today( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker.workout_data = {"type": "manual_workout"} + object.__setattr__( + locker, "_was_already_counted_today", MagicMock(return_value=True) + ) + object.__setattr__( + locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True) + ) + object.__setattr__( + locker, "_clear_debt_on_verified_workout", MagicMock(return_value=0) + ) + object.__setattr__(locker, "save_workout_log", MagicMock()) + + 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 still saved + + def test_applies_credit_when_not_already_counted( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker.workout_data = {"type": "manual_workout"} + object.__setattr__( + locker, "_was_already_counted_today", MagicMock(return_value=False) + ) + object.__setattr__( + locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True) + ) + object.__setattr__( + locker, "_clear_debt_on_verified_workout", MagicMock(return_value=2) + ) + object.__setattr__( + locker, "_read_shutdown_config", MagicMock(return_value=(21, 21, 5)) + ) + + 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 + + def test_extra_bonus_applied_above_weekly_minimum( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker.workout_data = {"type": "manual_workout"} + object.__setattr__( + locker, "_was_already_counted_today", MagicMock(return_value=False) + ) + 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, + "_read_shutdown_config", + MagicMock(side_effect=[(21, 21, 5), (22, 22, 5)]), + ) + object.__setattr__( + locker, "_adjust_shutdown_time_by", MagicMock(return_value=True) + ) + with patch( + "screen_locker._workout_credit.count_weekly_workouts", return_value=5 + ): + result = locker._apply_workout_credit() + + assert result.weekly_count == 5 + assert result.extra_bonus_delta == 1 + + def test_skips_save_for_sick_day_type( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker.workout_data = {"type": "sick_day"} + object.__setattr__( + locker, "_was_already_counted_today", MagicMock(return_value=False) + ) + 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()) + + locker._apply_workout_credit() + + locker.save_workout_log.assert_not_called() diff --git a/screen_locker/tests/test_manual_workout_status_view.py b/screen_locker/tests/test_manual_workout_status_view.py new file mode 100644 index 0000000..6d899cf --- /dev/null +++ b/screen_locker/tests/test_manual_workout_status_view.py @@ -0,0 +1,181 @@ +"""Tests for manual-workout integration in status_view.py.""" +# pylint: disable=protected-access + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker import _manual_workout +from screen_locker._workout_credit import WorkoutCreditResult +from screen_locker.tests._status_view_helpers import ( + _button_texts, + _make_window, + _manual_workout_budget, + _snapshot, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestSectionManualWorkoutBudget: + """Tests for StatusWindow._section_manual_workout_budget.""" + + def test_exhausted_uses_warning_color(self, mock_tk: MagicMock) -> None: + snap = _snapshot( + manual_workout_budget=_manual_workout_budget(used_7d=2, exhausted=True) + ) + _make_window(mock_tk, snap) + calls = [ + c + for c in mock_tk.Label.call_args_list + if "week" in c.kwargs.get("text", "") + ] + assert any(c.kwargs.get("fg") == "#ff4444" for c in calls) + + def test_not_exhausted_uses_normal_color(self, mock_tk: MagicMock) -> None: + snap = _snapshot( + manual_workout_budget=_manual_workout_budget(used_7d=0, exhausted=False) + ) + _make_window(mock_tk, snap) + calls = [ + c + for c in mock_tk.Label.call_args_list + if "week" in c.kwargs.get("text", "") + ] + assert any(c.kwargs.get("fg") == "#cccccc" for c in calls) + + +class TestManualWorkoutButtonVisibility: + """Tests for the "Log Manual Workout" button in StatusWindow.render.""" + + def test_shown_when_budget_available( + self, mock_tk: MagicMock, temp_log_file: Path + ) -> None: + _make_window(mock_tk, _snapshot(), log_file=temp_log_file) + assert "Log Manual Workout" in _button_texts(mock_tk) + + def test_hidden_when_budget_exhausted( + self, mock_tk: MagicMock, temp_log_file: Path + ) -> None: + with patch.object(_manual_workout, "is_budget_exhausted", return_value=True): + _make_window(mock_tk, _snapshot(), log_file=temp_log_file) + assert "Log Manual Workout" not in _button_texts(mock_tk) + + +class TestOnManualWorkoutSaved: + """Tests for StatusWindow._on_manual_workout_saved.""" + + def _make_fake_verifier(self, credit: WorkoutCreditResult) -> MagicMock: + fake_verifier = MagicMock() + fake_verifier._apply_workout_credit = MagicMock(return_value=credit) + return fake_verifier + + def test_saves_via_bare_verifier_and_shows_shutdown_message( + self, mock_tk: MagicMock, temp_log_file: Path + ) -> None: + credit = WorkoutCreditResult( + shutdown_adjusted=True, + new_debt=None, + extra_bonus_delta=0, + weekly_count=2, + already_counted_today=False, + ) + fake_verifier = self._make_fake_verifier(credit) + window = _make_window( + mock_tk, + _snapshot(), + log_file=temp_log_file, + verifier_factory=lambda _log_file: fake_verifier, + ) + entry = {"type": "manual_workout", "source": "table tennis at Solec"} + with patch("screen_locker.status_view.gather_status", return_value=_snapshot()): + window._on_manual_workout_saved(entry) + + assert fake_verifier.workout_data == entry + fake_verifier._apply_workout_credit.assert_called_once() + message = window._manual_workout_saved_message + assert message is not None + assert "table tennis at Solec" in message + assert "Shutdown time +2h later!" in message + + def test_shows_debt_and_extra_bonus_lines( + self, mock_tk: MagicMock, temp_log_file: Path + ) -> None: + credit = WorkoutCreditResult( + shutdown_adjusted=False, + new_debt=1, + extra_bonus_delta=1, + weekly_count=5, + already_counted_today=False, + ) + fake_verifier = self._make_fake_verifier(credit) + window = _make_window( + mock_tk, + _snapshot(), + log_file=temp_log_file, + verifier_factory=lambda _log_file: fake_verifier, + ) + with patch("screen_locker.status_view.gather_status", return_value=_snapshot()): + window._on_manual_workout_saved({"type": "manual_workout", "source": "x"}) + + message = window._manual_workout_saved_message + assert message is not None + assert "Extra workout #1! +1h tonight" in message + assert "Workout debt: 1" in message + + 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, + ) + fake_verifier = self._make_fake_verifier(credit) + window = _make_window( + mock_tk, + _snapshot(), + log_file=temp_log_file, + verifier_factory=lambda _log_file: fake_verifier, + ) + with patch("screen_locker.status_view.gather_status", return_value=_snapshot()): + window._on_manual_workout_saved({"type": "manual_workout", "source": "x"}) + + message = window._manual_workout_saved_message + assert message is not None + assert "no extra credit applied" in message + assert "Shutdown time" not in message + + +class TestOnManualWorkoutCancelled: + """Tests for StatusWindow._on_manual_workout_cancelled.""" + + def test_rerenders_current_snapshot( + self, mock_tk: MagicMock, temp_log_file: Path + ) -> None: + window = _make_window(mock_tk, _snapshot(), log_file=temp_log_file) + with patch( + "screen_locker.status_view.gather_status", return_value=_snapshot() + ) as mock_gather: + window._on_manual_workout_cancelled() + mock_gather.assert_called_once() + + +class TestManualWorkoutSavedMessageDisplay: + """Tests for the saved-message line in StatusWindow.render.""" + + def test_shown_when_present(self, mock_tk: MagicMock, temp_log_file: Path) -> None: + window = _make_window(mock_tk, _snapshot(), log_file=temp_log_file) + window._manual_workout_saved_message = "Manual workout logged: test" + window.render(_snapshot()) + texts = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list] + assert any("Manual workout logged: test" in t for t in texts) + + def test_absent_when_none(self, mock_tk: MagicMock, temp_log_file: Path) -> None: + _make_window(mock_tk, _snapshot(), log_file=temp_log_file) + texts = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list] + assert not any("Manual workout logged" in t for t in texts) diff --git a/screen_locker/tests/test_screen_lock_coverage_part2.py b/screen_locker/tests/test_screen_lock_coverage_part2.py index bc868ba..7379e76 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part2.py +++ b/screen_locker/tests/test_screen_lock_coverage_part2.py @@ -11,7 +11,6 @@ if TYPE_CHECKING: from pathlib import Path - class TestUnlockScreenExtras: """Tests for unlock_screen extra-workout bonus and streak display (360-389).""" @@ -59,7 +58,7 @@ class TestUnlockScreenExtras: with ( patch( - "screen_locker.screen_lock.count_weekly_workouts", + "screen_locker._workout_credit.count_weekly_workouts", return_value=5, ), patch( @@ -95,7 +94,7 @@ class TestUnlockScreenExtras: with ( patch( - "screen_locker.screen_lock.count_weekly_workouts", + "screen_locker._workout_credit.count_weekly_workouts", return_value=5, ), patch( @@ -126,7 +125,7 @@ class TestUnlockScreenExtras: with ( patch( - "screen_locker.screen_lock.count_weekly_workouts", + "screen_locker._workout_credit.count_weekly_workouts", return_value=3, ), patch( @@ -152,7 +151,7 @@ class TestUnlockScreenExtras: with ( patch( - "screen_locker.screen_lock.count_weekly_workouts", + "screen_locker._workout_credit.count_weekly_workouts", return_value=5, ), patch("screen_locker.screen_lock.current_streak", return_value=0), @@ -174,7 +173,7 @@ class TestUnlockScreenExtras: with ( patch( - "screen_locker.screen_lock.count_weekly_workouts", + "screen_locker._workout_credit.count_weekly_workouts", return_value=5, ), patch("screen_locker.screen_lock.current_streak", return_value=0), diff --git a/screen_locker/tests/test_status.py b/screen_locker/tests/test_status.py index b2b37b5..1ca3eae 100644 --- a/screen_locker/tests/test_status.py +++ b/screen_locker/tests/test_status.py @@ -5,7 +5,7 @@ from __future__ import annotations import json from pathlib import Path from typing import TYPE_CHECKING -from unittest.mock import patch +from unittest.mock import MagicMock, patch from screen_locker._status import _load_extra_benefits, _load_log, run_status from screen_locker.tests.conftest import _make_locker @@ -110,6 +110,30 @@ class TestRunStatusNormal: assert "No new workouts found" in out assert "Need" in out + def test_full_week_no_break_when_today_is_sunday( + self, tmp_path: Path, capsys: pytest.CaptureFixture + ) -> None: + """Today == the week's Sunday: the per-day loop runs all 7 days, never breaking early.""" + from datetime import datetime, timezone + + fixed_now = datetime(2026, 7, 5, 12, 0, tzinfo=timezone.utc) # a real Sunday + eb_file = tmp_path / "eb.json" + log_file = tmp_path / "log.json" + locker = _make_locker(log_file, n_filled=0) + mock_datetime_cls = MagicMock() + mock_datetime_cls.now.return_value = fixed_now + with ( + patch("screen_locker._status.datetime", mock_datetime_cls), + patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file), + patch("screen_locker._status.current_streak", return_value=0), + patch("screen_locker._status.has_extended_early_bird", return_value=False), + patch("screen_locker._status.count_weekly_workouts", return_value=0), + patch("sys.exit"), + ): + run_status(locker) + out = capsys.readouterr().out + assert out.count("no entry") == 7 + def test_sick_day_shown_when_no_log_entry( self, tmp_path: Path, capsys: pytest.CaptureFixture ) -> None: diff --git a/screen_locker/tests/test_status_data.py b/screen_locker/tests/test_status_data.py new file mode 100644 index 0000000..3f7be64 --- /dev/null +++ b/screen_locker/tests/test_status_data.py @@ -0,0 +1,354 @@ +"""Tests for the read-only status snapshot layer in _status_data.py.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from screen_locker._status_data import _day_status, format_summary_line, gather_status + +if TYPE_CHECKING: + from pathlib import Path + +# Fixed reference instant: Friday 2024-01-05, 12:00 UTC == 13:00 Europe/Warsaw. +# Outside the 05:00-09:00 early-bird window and not a Tue/Wed/Thu relaxed day, +# so lock-decision branches are fully deterministic regardless of wall clock. +_FRIDAY_NOON_UTC = datetime(2024, 1, 5, 12, 0, tzinfo=timezone.utc) +# Monday of that same ISO week, for Mon-Wed shutdown-band assertions. +_MONDAY_NOON_UTC = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc) + + +def _files(tmp_path: Path) -> dict[str, Path]: + return { + "log_file": tmp_path / "workout_log.json", + "extra_benefits_file": tmp_path / "extra_benefits_state.json", + "shutdown_base_file": tmp_path / "shutdown_base.json", + "shutdown_config_file": tmp_path / "shutdown_config.conf", + "scheduled_skips_file": tmp_path / "scheduled_skips.json", + "early_bird_pending_file": tmp_path / "early_bird_pending.json", + } + + +class TestGatherStatus: + """gather_status() end-to-end via tmp_path-backed state files.""" + + def test_empty_state_full_lock_by_default(self, tmp_path: Path) -> None: + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**_files(tmp_path), now=_FRIDAY_NOON_UTC) + + assert snap.today.date == "2024-01-05" + assert snap.today.entry_type is None + assert snap.today.counted is False + assert snap.today.is_sick_day is False + assert snap.week.counted_count == 0 + assert snap.week.remaining == 4 + assert snap.week.extra == 0 + assert snap.shutdown.tonight is None + assert snap.shutdown.rest_of_week[0].hour == 21 + 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 + assert snap.lock_explanation.stage == "full_lock_pending_heat_check" + + def test_populated_week_counts_workouts(self, tmp_path: Path) -> None: + files = _files(tmp_path) + files["log_file"].write_text( + json.dumps( + { + "2024-01-01": { + "workout_data": { + "type": "heat_skip", + "temperature_celsius": "34", + } + }, + "2024-01-03": { + "workout_data": {"type": "runnerup_verified", "source": "run"} + }, + "2024-01-05": { + "workout_data": {"type": "phone_verified", "source": "gym"} + }, + } + ) + ) + with ( + patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ), + patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=True + ), + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.week.counted_count == 2 + assert snap.today.entry_type == "phone_verified" + assert snap.today.source == "gym" + assert snap.today.counted is True + assert snap.lock_explanation.fired is False + assert snap.lock_explanation.stage == "already_logged" + + def test_sick_day_marks_today_and_skips_lock(self, tmp_path: Path) -> None: + files = _files(tmp_path) + (tmp_path / "sick_history.json").write_text( + json.dumps({"sick_days": ["2024-01-05"], "debt": 1}) + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.today.is_sick_day is True + assert snap.sick_budget.used_7d == 1 + assert snap.sick_budget.debt == 1 + assert snap.lock_explanation.fired is False + assert snap.lock_explanation.stage == "sick_day" + + def test_sick_budget_exhausted_flag(self, tmp_path: Path) -> None: + files = _files(tmp_path) + (tmp_path / "sick_history.json").write_text( + json.dumps({"sick_days": ["2024-01-05"], "debt": 0}) + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.sick_budget.exhausted is True + + def test_wake_alarm_skip_stops_lock(self, tmp_path: Path) -> None: + files = _files(tmp_path) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=True + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.lock_explanation.fired is False + assert snap.lock_explanation.stage == "wake_alarm_skip" + + def test_scheduled_skip_stops_lock(self, tmp_path: Path) -> None: + files = _files(tmp_path) + files["scheduled_skips_file"].write_text(json.dumps(["2024-01-05"])) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.lock_explanation.stage == "scheduled_skip" + + def test_relaxed_day_stops_lock(self, tmp_path: Path) -> None: + """Tuesday is a relaxed day — is_relaxed_day derives it from `now`.""" + files = _files(tmp_path) + tuesday_noon = datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=tuesday_noon) + + assert snap.lock_explanation.stage == "relaxed_day" + + def test_weekly_minimum_met_stops_lock(self, tmp_path: Path) -> None: + files = _files(tmp_path) + files["log_file"].write_text( + json.dumps( + { + d: {"workout_data": {"type": "phone_verified"}} + for d in ("2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04") + } + ) + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.week.counted_count == 4 + assert snap.week.remaining == 0 + assert snap.week.extra == 0 + assert snap.lock_explanation.stage == "weekly_minimum_met" + + def test_extra_workouts_beyond_minimum(self, tmp_path: Path) -> None: + files = _files(tmp_path) + files["log_file"].write_text( + json.dumps( + { + d: {"workout_data": {"type": "phone_verified"}} + for d in ( + "2024-01-01", + "2024-01-02", + "2024-01-03", + "2024-01-04", + "2024-01-05", + ) + } + ) + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.week.extra == 1 + assert snap.week.remaining == 0 + + def test_shutdown_config_present_reflected_in_tonight(self, tmp_path: Path) -> None: + files = _files(tmp_path) + files["shutdown_config_file"].write_text( + "MON_WED_HOUR=22\nTHU_SUN_HOUR=23\nMORNING_END_HOUR=5\n" + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.shutdown.tonight == (22, 23, 5) + + def test_bonus_streak_and_extended_early_bird_reflected( + self, tmp_path: Path + ) -> None: + files = _files(tmp_path) + files["extra_benefits_file"].write_text( + json.dumps( + { + "consecutive_5plus_weeks": 2, + "weekly_shutdown_bonus_hours": {"2024-W01": 3}, + "extended_early_bird_iso_weeks": ["2024-W01"], + } + ) + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.bonus_hours_this_week == 3 + assert snap.streak == 2 + assert snap.early_bird_extended is True + assert snap.shutdown.rest_of_week[0].hour == 24 # 21 base + 3 bonus + + def test_next_week_preview_is_speculative_rest_of_week_is_not( + self, tmp_path: Path + ) -> None: + files = _files(tmp_path) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert all(d.speculative is False for d in snap.shutdown.rest_of_week) + assert all(d.speculative is True for d in snap.shutdown.next_week_preview) + assert len(snap.shutdown.rest_of_week) == 7 + assert len(snap.shutdown.next_week_preview) == 7 + + def test_corrupt_log_file_treated_as_empty(self, tmp_path: Path) -> None: + files = _files(tmp_path) + files["log_file"].write_text("{not valid json") + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.week.counted_count == 0 + assert snap.today.entry_type is None + + def test_now_defaults_to_current_time_when_omitted(self, tmp_path: Path) -> None: + """Covers the ``now is None`` branch — just needs to not raise.""" + files = _files(tmp_path) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files) + + assert isinstance(snap.generated_at, str) + datetime.fromisoformat(snap.generated_at) # must parse without raising + + +class TestDayStatus: + """Direct tests for the _day_status helper's branch on non-dict entries.""" + + def test_entry_none_not_sick(self) -> None: + day = _day_status(date(2024, 1, 5), None, set()) + assert day.entry_type is None + assert day.counted is False + assert day.is_sick_day is False + + def test_entry_none_but_sick(self) -> None: + day = _day_status(date(2024, 1, 5), None, {"2024-01-05"}) + assert day.is_sick_day is True + + def test_entry_not_a_dict(self) -> None: + """Corrupt log data (non-dict entry) falls back to empty workout_data.""" + day = _day_status(date(2024, 1, 5), "corrupt-string-entry", set()) + assert day.entry_type is None + assert day.counted is False + assert day.source == "" + + +class TestFormatSummaryLine: + """format_summary_line variants.""" + + def test_checkmark_when_week_complete(self, tmp_path: Path) -> None: + files = _files(tmp_path) + files["log_file"].write_text( + json.dumps( + { + d: {"workout_data": {"type": "phone_verified"}} + for d in ("2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04") + } + ) + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert format_summary_line(snap).startswith("✓") + + def test_ellipsis_when_week_incomplete(self, tmp_path: Path) -> None: + files = _files(tmp_path) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert format_summary_line(snap).startswith("…") + + def test_question_mark_when_no_shutdown_config(self, tmp_path: Path) -> None: + files = _files(tmp_path) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert "?:00 tonight" not in format_summary_line(snap) + assert "? tonight" in format_summary_line(snap) + + def test_mon_wed_band_used_on_monday(self, tmp_path: Path) -> None: + files = _files(tmp_path) + files["shutdown_config_file"].write_text( + "MON_WED_HOUR=20\nTHU_SUN_HOUR=23\nMORNING_END_HOUR=5\n" + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_MONDAY_NOON_UTC) + + assert "20:00 tonight" in format_summary_line(snap) + + def test_thu_sun_band_used_on_friday(self, tmp_path: Path) -> None: + files = _files(tmp_path) + files["shutdown_config_file"].write_text( + "MON_WED_HOUR=20\nTHU_SUN_HOUR=23\nMORNING_END_HOUR=5\n" + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert "23:00 tonight" in format_summary_line(snap) diff --git a/screen_locker/tests/test_status_view.py b/screen_locker/tests/test_status_view.py new file mode 100644 index 0000000..4e1fa77 --- /dev/null +++ b/screen_locker/tests/test_status_view.py @@ -0,0 +1,170 @@ +"""Tests for the read-only Tkinter status window's rendering (StatusWindow.render).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from screen_locker._compliance_state import AutoUpgradeOpportunity +from screen_locker.tests._status_view_helpers import ( + _day, + _lock_explanation, + _make_window, + _shutdown, + _sick_budget, + _snapshot, + _texts, + _week, +) + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + +class TestSectionToday: + def test_counted_shows_checkmark_and_entry(self, mock_tk: MagicMock) -> None: + snap = _snapshot( + today=_day(entry_type="phone_verified", counted=True, source="gym") + ) + _make_window(mock_tk, snap) + texts = _texts(mock_tk) + assert any("✓" in t and "phone_verified" in t for t in texts) + assert any(t == "gym" for t in texts) + + def test_sick_day_shows_sick_mark(self, mock_tk: MagicMock) -> None: + snap = _snapshot(today=_day(entry_type=None, is_sick_day=True)) + _make_window(mock_tk, snap) + assert any("😷" in t and "sick day" in t for t in _texts(mock_tk)) + + def test_no_entry_shows_dash(self, mock_tk: MagicMock) -> None: + snap = _snapshot(today=_day(entry_type=None, is_sick_day=False)) + _make_window(mock_tk, snap) + assert any("no entry yet" in t for t in _texts(mock_tk)) + + def test_empty_source_adds_no_extra_line(self, mock_tk: MagicMock) -> None: + snap = _snapshot( + today=_day(entry_type="phone_verified", counted=True, source="") + ) + _make_window(mock_tk, snap) + # Only the main "Today (...)" line, no second call for an empty source. + today_related = [t for t in _texts(mock_tk) if "Today" in t] + assert len(today_related) == 1 + + +class TestSectionWeek: + def test_shows_remaining_message_when_under_minimum( + self, mock_tk: MagicMock + ) -> None: + snap = _snapshot(week=_week(counted_count=2, remaining=2, extra=0)) + _make_window(mock_tk, snap) + assert any("Need 2 more this week." in t for t in _texts(mock_tk)) + + def test_shows_extra_message_when_over_minimum(self, mock_tk: MagicMock) -> None: + snap = _snapshot(week=_week(counted_count=5, remaining=0, extra=1)) + _make_window(mock_tk, snap) + assert any("above the weekly minimum" in t for t in _texts(mock_tk)) + + def test_shows_neither_message_at_exact_minimum(self, mock_tk: MagicMock) -> None: + snap = _snapshot(week=_week(counted_count=4, remaining=0, extra=0)) + _make_window(mock_tk, snap) + texts = _texts(mock_tk) + assert not any("Need" in t and "more this week" in t for t in texts) + assert not any("above the weekly minimum" in t for t in texts) + + def test_renders_a_line_per_day(self, mock_tk: MagicMock) -> None: + days = ( + _day( + date="2024-01-01", + label="Mon Jan 01", + counted=True, + entry_type="phone_verified", + ), + _day(date="2024-01-02", label="Tue Jan 02", is_sick_day=True), + _day(date="2024-01-03", label="Wed Jan 03"), + ) + snap = _snapshot(week=_week(days=days)) + _make_window(mock_tk, snap) + texts = _texts(mock_tk) + assert any("Mon Jan 01" in t and "✓" in t for t in texts) + assert any("Tue Jan 02" in t and "😷" in t for t in texts) + assert any("Wed Jan 03" in t and "no entry" in t for t in texts) + + +class TestSectionLockExplanation: + def test_fired_with_auto_upgrade_and_heat_skip_pending( + self, mock_tk: MagicMock + ) -> None: + snap = _snapshot( + lock_explanation=_lock_explanation( + fired=True, + reason="Full lock.", + auto_upgrade=AutoUpgradeOpportunity( + would_attempt=True, via="sick_day", reason="will try phone" + ), + heat_skip_evaluated=False, + ) + ) + _make_window(mock_tk, snap) + texts = _texts(mock_tk) + assert any(t == "Full lock." for t in texts) + assert any("Pending auto-upgrade: will try phone" in t for t in texts) + assert any("Live Warsaw temperature" in t for t in texts) + + def test_not_fired_no_auto_upgrade_no_heat_line(self, mock_tk: MagicMock) -> None: + snap = _snapshot( + lock_explanation=_lock_explanation(fired=False, reason="Skipped.") + ) + _make_window(mock_tk, snap) + texts = _texts(mock_tk) + assert any(t == "Skipped." for t in texts) + assert not any("Pending auto-upgrade" in t for t in texts) + assert not any("Live Warsaw temperature" in t for t in texts) + + def test_fired_but_heat_skip_already_evaluated_no_heat_line( + self, mock_tk: MagicMock + ) -> None: + snap = _snapshot( + lock_explanation=_lock_explanation(fired=True, heat_skip_evaluated=True) + ) + _make_window(mock_tk, snap) + assert not any("Live Warsaw temperature" in t for t in _texts(mock_tk)) + + +class TestSectionSickBudget: + def test_exhausted_uses_warning_color(self, mock_tk: MagicMock) -> None: + snap = _snapshot(sick_budget=_sick_budget(used_7d=1, exhausted=True)) + _make_window(mock_tk, snap) + calls = [ + c + for c in mock_tk.Label.call_args_list + if "week" in c.kwargs.get("text", "") + ] + assert any(c.kwargs.get("fg") == "#ff4444" for c in calls) + + def test_not_exhausted_uses_normal_color(self, mock_tk: MagicMock) -> None: + snap = _snapshot(sick_budget=_sick_budget(used_7d=0, exhausted=False)) + _make_window(mock_tk, snap) + calls = [ + c + for c in mock_tk.Label.call_args_list + if "week" in c.kwargs.get("text", "") + ] + assert any(c.kwargs.get("fg") == "#cccccc" for c in calls) + + +class TestSectionShutdown: + def test_tonight_present_shows_live_config(self, mock_tk: MagicMock) -> None: + snap = _snapshot(shutdown=_shutdown(tonight=(22, 23, 5))) + _make_window(mock_tk, snap) + assert any("22:00" in t and "23:00" in t for t in _texts(mock_tk)) + + def test_tonight_absent_shows_unavailable_message(self, mock_tk: MagicMock) -> None: + snap = _snapshot(shutdown=_shutdown(tonight=None)) + _make_window(mock_tk, snap) + assert any("Live shutdown config unavailable." in t for t in _texts(mock_tk)) + + def test_shows_rest_of_week_and_next_week_preview(self, mock_tk: MagicMock) -> None: + snap = _snapshot() + _make_window(mock_tk, snap) + texts = _texts(mock_tk) + assert any("Rest of week:" in t for t in texts) + assert any("Next week (speculative):" in t for t in texts) diff --git a/screen_locker/tests/test_status_view_part2.py b/screen_locker/tests/test_status_view_part2.py new file mode 100644 index 0000000..a8e63bf --- /dev/null +++ b/screen_locker/tests/test_status_view_part2.py @@ -0,0 +1,222 @@ +"""Tests for status_view.py: phone-check flow, buttons, main() CLI entry point.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker.status_view import ( + StatusWindow, + _compliance_state_word, + _make_bare_verifier, + main, +) +from screen_locker.tests._status_view_helpers import ( + _button_texts, + _lock_explanation, + _make_window, + _snapshot, + _texts, + _week, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + import pytest + + +class TestPhoneCheckResultRendering: + def test_no_result_shows_nothing(self, mock_tk: MagicMock) -> None: + snap = _snapshot() + _make_window(mock_tk, snap) + assert not any("Phone check" in t for t in _texts(mock_tk)) + + def test_verified_result_shown_in_green(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + window._phone_check_result = ("verified", "Workout verified!") + window.render(_snapshot()) + calls = [ + c + for c in mock_tk.Label.call_args_list + if "Phone check" in c.kwargs.get("text", "") + ] + assert calls + assert calls[-1].kwargs.get("fg") == "#00cc44" + + def test_non_verified_result_shown_in_orange(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + window._phone_check_result = ("no_phone", "Phone not connected") + window.render(_snapshot()) + calls = [ + c + for c in mock_tk.Label.call_args_list + if "Phone check" in c.kwargs.get("text", "") + ] + assert calls + assert calls[-1].kwargs.get("fg") == "#ff8844" + + +class TestButtons: + def test_renders_check_phone_refresh_close( + self, mock_tk: MagicMock, temp_log_file: Path + ) -> None: + _make_window(mock_tk, _snapshot(), log_file=temp_log_file) + assert _button_texts(mock_tk) == { + "Check Phone", + "Log Manual Workout", + "Refresh", + "Close", + } + + +class TestRefreshAndCheckPhoneFlow: + def test_refresh_clears_phone_result_and_calls_on_refresh( + self, mock_tk: MagicMock + ) -> None: + on_refresh = MagicMock() + window = _make_window(mock_tk, _snapshot(), on_refresh=on_refresh) + window._phone_check_result = ("verified", "x") + window._on_refresh_clicked() + assert window._phone_check_result is None + on_refresh.assert_called_once() + + def test_check_phone_submits_future(self, mock_tk: MagicMock) -> None: + """submit() always returns a Future synchronously. + + Whether the trivial mock call resolves before or after the immediate + follow-up poll check is a genuine race (background thread vs. this + thread) — not something to assert either way. The poll-routing logic + itself is tested deterministically below with hand-built futures. + """ + fake_verifier = MagicMock() + fake_verifier._verify_phone_workout = MagicMock(return_value=("verified", "ok")) + window = _make_window( + mock_tk, _snapshot(), verifier_factory=lambda _log_file: fake_verifier + ) + with patch("screen_locker.status_view.gather_status", return_value=_snapshot()): + window._on_check_phone_clicked() + assert window._phone_future is not None + + def test_poll_routes_to_result_when_future_done(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + mock_future = MagicMock() + mock_future.done.return_value = True + mock_future.result.return_value = ("verified", "ok") + window._phone_future = mock_future + with patch.object(window, "_on_phone_check_result") as mock_handle: + window._poll_phone_check() + mock_handle.assert_called_once_with("verified", "ok") + + def test_poll_waits_when_future_not_done(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + mock_future = MagicMock() + mock_future.done.return_value = False + window._phone_future = mock_future + with patch.object(window, "_on_phone_check_result") as mock_handle: + window._poll_phone_check() + mock_handle.assert_not_called() + window.root.after.assert_called_with(500, window._poll_phone_check) + + def test_poll_waits_when_future_is_none(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + window._phone_future = None + with patch.object(window, "_on_phone_check_result") as mock_handle: + window._poll_phone_check() + mock_handle.assert_not_called() + + def test_result_never_calls_save_workout_log(self, mock_tk: MagicMock) -> None: + """A manual peek must never silently log a workout (see module docstring).""" + window = _make_window(mock_tk, _snapshot()) + with patch( + "screen_locker.status_view.gather_status", return_value=_snapshot() + ) as mock_gather: + window._on_phone_check_result("verified", "Workout verified!") + assert window._phone_check_result == ("verified", "Workout verified!") + mock_gather.assert_called_once() + + +class TestMakeBareVerifier: + def test_builds_locker_without_running_init(self, tmp_path: Path) -> None: + log_file = tmp_path / "workout_log.json" + verifier = _make_bare_verifier(log_file) + assert verifier.log_file == log_file + assert verifier.workout_data == {} + + +class TestComplianceStateWord: + def test_lock_when_fired(self) -> None: + snap = _snapshot(lock_explanation=_lock_explanation(fired=True)) + assert _compliance_state_word(snap) == "lock" + + def test_warn_when_not_fired_but_remaining(self) -> None: + snap = _snapshot( + lock_explanation=_lock_explanation(fired=False), + week=_week(remaining=2), + ) + assert _compliance_state_word(snap) == "warn" + + def test_ok_when_not_fired_and_minimum_met(self) -> None: + snap = _snapshot( + lock_explanation=_lock_explanation(fired=False), + week=_week(remaining=0), + ) + assert _compliance_state_word(snap) == "ok" + + +class TestMain: + def test_summary_flag_prints_and_returns( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with patch("screen_locker.status_view.gather_status", return_value=_snapshot()): + main(["--summary"]) + out = capsys.readouterr().out + assert "workouts" in out + + def test_state_flag_prints_and_returns( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with patch( + "screen_locker.status_view.gather_status", + return_value=_snapshot(lock_explanation=_lock_explanation(fired=True)), + ): + main(["--state"]) + out = capsys.readouterr().out + assert out.strip() == "lock" + + def test_no_flags_opens_window(self, mock_tk: MagicMock) -> None: + with patch("screen_locker.status_view.gather_status", return_value=_snapshot()): + main([]) + mock_tk.Tk.assert_called_once() + mock_tk.Tk.return_value.mainloop.assert_called_once() + + def test_no_flags_refresh_closure_regathers_and_rerenders( + self, mock_tk: MagicMock + ) -> None: + """Exercises main()'s inner refresh() closure passed as on_refresh.""" + captured: dict[str, Callable[[], None]] = {} + + class _CapturingWindow(StatusWindow): + def __init__( + self, + root: object, + snapshot: object, + *, + on_refresh: Callable[[], None], + **kwargs: object, + ) -> None: + captured["on_refresh"] = on_refresh + super().__init__(root, snapshot, on_refresh=on_refresh, **kwargs) + + with ( + patch( + "screen_locker.status_view.gather_status", return_value=_snapshot() + ) as mock_gather, + patch("screen_locker.status_view.StatusWindow", _CapturingWindow), + ): + main([]) + calls_before = mock_gather.call_count + captured["on_refresh"]() + + assert mock_gather.call_count == calls_before + 1 diff --git a/screen_locker/tests/test_temperature.py b/screen_locker/tests/test_temperature.py new file mode 100644 index 0000000..7af8454 --- /dev/null +++ b/screen_locker/tests/test_temperature.py @@ -0,0 +1,98 @@ +"""Tests for wttr.in temperature fetching used by the heat-skip feature.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch +import urllib.error + +from screen_locker._temperature import fetch_current_temp_celsius, is_too_hot + + +def _mock_urlopen_returning(body: bytes) -> MagicMock: + mock_response = MagicMock() + mock_response.read.return_value = body + mock_urlopen = MagicMock() + mock_urlopen.return_value.__enter__.return_value = mock_response + return mock_urlopen + + +class TestFetchCurrentTempCelsius: + def test_returns_parsed_temperature_on_success(self) -> None: + body = b'{"current_condition": [{"temp_C": "27"}]}' + with patch( + "screen_locker._temperature.urllib.request.urlopen", + _mock_urlopen_returning(body), + ): + assert fetch_current_temp_celsius("Warsaw") == 27.0 + + def test_returns_none_on_url_error(self) -> None: + with patch( + "screen_locker._temperature.urllib.request.urlopen", + side_effect=urllib.error.URLError("unreachable"), + ): + assert fetch_current_temp_celsius("Warsaw") is None + + def test_returns_none_on_timeout(self) -> None: + with patch( + "screen_locker._temperature.urllib.request.urlopen", + side_effect=TimeoutError("timed out"), + ): + assert fetch_current_temp_celsius("Warsaw") is None + + def test_returns_none_on_os_error(self) -> None: + with patch( + "screen_locker._temperature.urllib.request.urlopen", + side_effect=OSError("network unreachable"), + ): + assert fetch_current_temp_celsius("Warsaw") is None + + def test_returns_none_on_missing_key(self) -> None: + body = b'{"unexpected": "shape"}' + with patch( + "screen_locker._temperature.urllib.request.urlopen", + _mock_urlopen_returning(body), + ): + assert fetch_current_temp_celsius("Warsaw") is None + + def test_returns_none_on_empty_current_condition(self) -> None: + body = b'{"current_condition": []}' + with patch( + "screen_locker._temperature.urllib.request.urlopen", + _mock_urlopen_returning(body), + ): + assert fetch_current_temp_celsius("Warsaw") is None + + def test_returns_none_on_non_numeric_temp(self) -> None: + body = b'{"current_condition": [{"temp_C": "not-a-number"}]}' + with patch( + "screen_locker._temperature.urllib.request.urlopen", + _mock_urlopen_returning(body), + ): + assert fetch_current_temp_celsius("Warsaw") is None + + def test_returns_none_on_invalid_json(self) -> None: + with patch( + "screen_locker._temperature.urllib.request.urlopen", + _mock_urlopen_returning(b"not json{{{"), + ): + assert fetch_current_temp_celsius("Warsaw") is None + + +class TestIsTooHot: + def test_returns_none_when_fetch_fails(self) -> None: + with patch( + "screen_locker._temperature.fetch_current_temp_celsius", return_value=None + ): + assert is_too_hot("Warsaw", 33) is None + + def test_returns_none_when_below_threshold(self) -> None: + with patch( + "screen_locker._temperature.fetch_current_temp_celsius", return_value=20.0 + ): + assert is_too_hot("Warsaw", 33) is None + + def test_returns_temp_when_at_or_above_threshold(self) -> None: + with patch( + "screen_locker._temperature.fetch_current_temp_celsius", return_value=33.0 + ): + assert is_too_hot("Warsaw", 33) == 33.0 diff --git a/screen_locker/tests/test_weekly_logic_part2.py b/screen_locker/tests/test_weekly_logic_part2.py index 4a6b264..578b1b8 100644 --- a/screen_locker/tests/test_weekly_logic_part2.py +++ b/screen_locker/tests/test_weekly_logic_part2.py @@ -5,13 +5,12 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch +from screen_locker.screen_lock import ScreenLocker from screen_locker.tests.conftest import create_locker, create_locker_relaxed_day if TYPE_CHECKING: from pathlib import Path - from screen_locker.screen_lock import ScreenLocker - # --------------------------------------------------------------------------- # _check_today_state_exits: return True/False branches # --------------------------------------------------------------------------- @@ -199,3 +198,64 @@ class TestRelaxedDayCloseAndRun: locker.close() locker.root.destroy.assert_called_once() + + +# --------------------------------------------------------------------------- +# _check_non_verify_exits: heat-skip branch (reached after weekly minimum +# is not met — the only remaining same-day skip is genuine extreme heat) +# --------------------------------------------------------------------------- + + +class TestHeatSkipBranch: + def test_not_too_hot_no_dialog_shown( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + with ( + patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), + patch( + "screen_locker.screen_lock.is_too_hot", return_value=None + ) as mock_hot, + patch.object(ScreenLocker, "_show_heat_skip_dialog") as mock_dialog, + ): + create_locker(mock_tk, tmp_path, has_logged=False) + + mock_hot.assert_called_once() + mock_dialog.assert_not_called() + mock_sys_exit.assert_not_called() + + def test_too_hot_and_user_confirms_skip( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + with ( + patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), + patch("screen_locker.screen_lock.is_too_hot", return_value=35.0), + patch.object(ScreenLocker, "_show_heat_skip_dialog", return_value=True), + patch.object(ScreenLocker, "_save_heat_skip_log") as mock_save, + ): + create_locker(mock_tk, tmp_path, has_logged=False) + + mock_save.assert_called_once_with(35.0) + mock_sys_exit.assert_called_once_with(0) + + def test_too_hot_but_user_declines_skip( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + with ( + patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), + patch("screen_locker.screen_lock.is_too_hot", return_value=35.0), + patch.object(ScreenLocker, "_show_heat_skip_dialog", return_value=False), + patch.object(ScreenLocker, "_save_heat_skip_log") as mock_save, + ): + create_locker(mock_tk, tmp_path, has_logged=False) + + mock_save.assert_not_called() + mock_sys_exit.assert_not_called() diff --git a/stronglift_replacement/workout_app/android/app/src/main/AndroidManifest.xml b/stronglift_replacement/workout_app/android/app/src/main/AndroidManifest.xml index 4abe52b..0263813 100644 --- a/stronglift_replacement/workout_app/android/app/src/main/AndroidManifest.xml +++ b/stronglift_replacement/workout_app/android/app/src/main/AndroidManifest.xml @@ -8,8 +8,6 @@ uninstall/reinstall on Android 11+ where legacy permissions are revoked. --> - - createState() => _WorkoutAppState(); +} + +class _WorkoutAppState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.detached: + unawaited(HttpServerService.instance.stop()); + case AppLifecycleState.resumed: + unawaited(HttpServerService.instance.start()); + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + break; + } + } + @override Widget build(BuildContext context) { return MaterialApp(