Add manual (unverified) workout logging with evidence form
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPcvAvt1JhiRzEyPDE5dA
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-07-05 20:08:52 +02:00
parent 58a33938e3
commit d9119a6582
43 changed files with 5249 additions and 224 deletions

8
.gitignore vendored
View File

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

View File

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

View File

@ -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/<device_id>/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/<device_id>/...`,
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/<app>/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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ("<<Paste>>", "<Control-v>", "<Control-V>", "<Button-2>"):
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:

View File

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

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
from typing import TYPE_CHECKING
from screen_locker import _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."""

View File

@ -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 ("<<Paste>>", "<Control-v>", "<Control-V>", "<Button-2>"):
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,8 +8,6 @@
uninstall/reinstall on Android 11+ where legacy permissions are revoked. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Wake lock keeps timer running in background -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Required to bind a server socket for the HTTP fallback sync endpoint -->
<uses-permission android:name="android.permission.INTERNET" />
<application

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:workout_app/screens/home_screen.dart';
import 'package:workout_app/services/backup_service.dart';
@ -14,10 +16,45 @@ void main() async {
}
/// Root widget that bootstraps the app with Material 3 dark theming.
class WorkoutApp extends StatelessWidget {
///
/// Also owns the app-lifecycle observer that stops [HttpServerService]'s
/// listening socket while the app is backgrounded, so the process (and its
/// open socket) doesn't sit alive indefinitely and block Doze/App-Standby.
class WorkoutApp extends StatefulWidget {
/// Creates the root app widget.
const WorkoutApp({super.key});
@override
State<WorkoutApp> createState() => _WorkoutAppState();
}
class _WorkoutAppState extends State<WorkoutApp> 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(