mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-06 16:03:10 +02:00
Add manual (unverified) workout logging with evidence form
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:
parent
58a33938e3
commit
d9119a6582
8
.gitignore
vendored
8
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
153
docs/todo/workstream-c-github-sync-workout-data.md
Normal file
153
docs/todo/workstream-c-github-sync-workout-data.md
Normal 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)
|
||||
145
docs/todo/workstream-d-scoping-findings.md
Normal file
145
docs/todo/workstream-d-scoping-findings.md
Normal 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) | **M–L** for no behavioral gain — see sub-decision 2 | **S** if just swapping Gist → Contents-API for transport consistency; **none needed** if left as-is |
|
||||
| screen-locker | Append-only log (workout entries), net-new sync | Nothing exists yet | **S** — greenfield, adopts whatever the shared lib provides directly | **S** — same |
|
||||
|
||||
## 4. Sequencing relative to Workstream C
|
||||
|
||||
The prior "C waits for D" decision was made when D was "not started, not
|
||||
scoped" (2026-07-04, same day). This scoping pass changes what's actually
|
||||
being waited on: the *transport* piece (what C needs first — Contents-API
|
||||
client) is small, low-risk, and effectively already designed by example in
|
||||
diet-guard's `_sync_github.py`. The *merge-unification* question (does one
|
||||
scheme cover all four apps, per sub-decision 1) is the open, larger piece —
|
||||
and screen-locker's own data (an append-only workout log, same shape as
|
||||
diet-guard's) doesn't actually need that question resolved first, since the
|
||||
diet-guard-style tombstone scheme already fits it directly.
|
||||
|
||||
**Flagging, not deciding:** it may be possible to unblock Workstream C on
|
||||
just the shared transport client + the diet-guard-style merge scheme,
|
||||
without waiting for a resolution on whether/how todo's different merge
|
||||
problem folds into the "shared" library. That's a sequencing option to
|
||||
confirm with the user, not something this pass resolves unilaterally.
|
||||
|
||||
## Sub-decisions — resolved (2026-07-04, user call, overriding this doc's recommendations)
|
||||
|
||||
Both sub-decisions below were put to the user with an explicit recommendation
|
||||
each way this pass leaned against; the user considered the trade-off and
|
||||
decided the other way on both. Recorded here as settled, not open:
|
||||
|
||||
1. **One unified merge scheme, covering all four apps including todo.**
|
||||
This means the shared library's merge scheme must be extended beyond the
|
||||
diet-guard-style tombstone-log scheme to also handle todo's mutable,
|
||||
per-field-edited notes — i.e. it needs to grow something equivalent to
|
||||
`sqlite_crdt`'s per-column HLC last-writer-wins, in Python and Dart, not
|
||||
just the simpler append/tombstone case. This is materially more design
|
||||
and implementation work than the transport-only extraction (was flagged
|
||||
as **L** cost in the table above) — budget for it accordingly when this
|
||||
moves to an implementation plan.
|
||||
2. **wake-alarm converts to CRDT merge too**, despite its data (single
|
||||
current wake-time value, one writer, no history) having no actual
|
||||
concurrent-edit conflict for a CRDT to resolve. This is consistency-driven
|
||||
rather than need-driven — noted so a future implementer isn't confused
|
||||
about why a single-scalar value is running through CRDT merge logic; it's
|
||||
an intentional choice, not an oversight.
|
||||
|
||||
Net effect on scope: this makes Workstream D closer to its original framing
|
||||
(one CRDT scheme, all four apps) than the leaner split this pass initially
|
||||
recommended. The Python-CRDT-landscape conclusion from section 1 still holds
|
||||
regardless — no third-party package fits, so the unified scheme is still
|
||||
something to build by generalizing diet-guard's approach and folding in
|
||||
per-field LWW for todo/wake-alarm's single-value case, not something to pull
|
||||
off the shelf.
|
||||
|
||||
## Critical files referenced (read-only during this pass)
|
||||
|
||||
- `~/todo/lib/sync/sync_service.dart`, `~/todo/lib/data/note_repository.dart`
|
||||
- `~/diet-guard/diet_guard/_sync.py`, `_sync_github.py`, `_sync_merge.py`
|
||||
- `~/diet-guard/app/lib/services/sync_merge.dart`
|
||||
- `~/wake-alarm/shutdown-wrapper.sh`
|
||||
- `docs/todo/workstream-c-github-sync-workout-data.md` (this repo)
|
||||
137
docs/todo/workstream-d-shared-crdt-transport.md
Normal file
137
docs/todo/workstream-d-shared-crdt-transport.md
Normal 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)
|
||||
@ -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]
|
||||
|
||||
383
screen_locker/_compliance_state.py
Normal file
383
screen_locker/_compliance_state.py
Normal 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,
|
||||
)
|
||||
@ -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/
|
||||
|
||||
@ -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)."""
|
||||
|
||||
@ -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", []
|
||||
|
||||
@ -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)
|
||||
|
||||
312
screen_locker/_manual_workout.py
Normal file
312
screen_locker/_manual_workout.py
Normal 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
|
||||
357
screen_locker/_manual_workout_dialog.py
Normal file
357
screen_locker/_manual_workout_dialog.py
Normal 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)
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
371
screen_locker/_status_data.py
Normal file
371
screen_locker/_status_data.py
Normal 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}"
|
||||
)
|
||||
@ -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."""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
137
screen_locker/_workout_credit.py
Normal file
137
screen_locker/_workout_credit.py
Normal 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,
|
||||
)
|
||||
@ -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),
|
||||
|
||||
352
screen_locker/status_view.py
Normal file
352
screen_locker/status_view.py
Normal 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()
|
||||
161
screen_locker/tests/_status_view_helpers.py
Normal file
161
screen_locker/tests/_status_view_helpers.py
Normal 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
|
||||
)
|
||||
@ -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"
|
||||
|
||||
275
screen_locker/tests/test_compliance_state.py
Normal file
275
screen_locker/tests/test_compliance_state.py
Normal 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"
|
||||
203
screen_locker/tests/test_compliance_state_part2.py
Normal file
203
screen_locker/tests/test_compliance_state_part2.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
66
screen_locker/tests/test_heat_skip.py
Normal file
66
screen_locker/tests/test_heat_skip.py
Normal 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()
|
||||
@ -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",
|
||||
),
|
||||
):
|
||||
|
||||
300
screen_locker/tests/test_manual_workout.py
Normal file
300
screen_locker/tests/test_manual_workout.py
Normal 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"
|
||||
209
screen_locker/tests/test_manual_workout_dialog.py
Normal file
209
screen_locker/tests/test_manual_workout_dialog.py
Normal 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()
|
||||
235
screen_locker/tests/test_manual_workout_features.py
Normal file
235
screen_locker/tests/test_manual_workout_features.py
Normal 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()
|
||||
181
screen_locker/tests/test_manual_workout_status_view.py
Normal file
181
screen_locker/tests/test_manual_workout_status_view.py
Normal 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)
|
||||
@ -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),
|
||||
|
||||
@ -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:
|
||||
|
||||
354
screen_locker/tests/test_status_data.py
Normal file
354
screen_locker/tests/test_status_data.py
Normal 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)
|
||||
170
screen_locker/tests/test_status_view.py
Normal file
170
screen_locker/tests/test_status_view.py
Normal 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)
|
||||
222
screen_locker/tests/test_status_view_part2.py
Normal file
222
screen_locker/tests/test_status_view_part2.py
Normal 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
|
||||
98
screen_locker/tests/test_temperature.py
Normal file
98
screen_locker/tests/test_temperature.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user