mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 22:27:12 +01:00
Compare commits
91 Commits
v1.0.0-bui
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e08a520019 | |||
| 94f9822884 | |||
| 94a55deb86 | |||
| 13f1e9ed93 | |||
| 6777656418 | |||
| 9c415d8f9a | |||
| 9641906818 | |||
| cd32c0a79e | |||
| 48d21bfafc | |||
| 6df7c397ca | |||
| 882b6a56c8 | |||
| fccde84bcb | |||
| 43e7f6d7e0 | |||
| 8cc66d2803 | |||
| d066726a60 | |||
| f528722e1f | |||
| 0a48b76d59 | |||
| ba9e9c31c0 | |||
| d0bb8d700e | |||
| 1475ab913b | |||
| 2998bd5353 | |||
| 0ec8cfddec | |||
| 39c85a7dfd | |||
| 5d3547c221 | |||
| 7f9169b9d0 | |||
| 17dac66a11 | |||
| 23ad2e02b4 | |||
| aaaecca3be | |||
| bd12b8711d | |||
| 97d73b31f5 | |||
| 66e9948777 | |||
| 801e303b39 | |||
| 6fa9666cdd | |||
| b1ff91249b | |||
| 0411116f24 | |||
| 770a88454d | |||
| a6c97dab8f | |||
| 8ca4c301fd | |||
| ca205fa67e | |||
| 25ee5ca9ad | |||
| 50faee673f | |||
| b174339979 | |||
| 8a26387396 | |||
| b4e69dbff4 | |||
| 63caf9dee9 | |||
| c2544c4a9c | |||
| 1020421371 | |||
| c4d2bbcd7d | |||
| 595c5f41e6 | |||
| 856f0c0774 | |||
| 0987975bca | |||
| 6919ddea28 | |||
| fdf6251847 | |||
| 47da4131a8 | |||
| c77dff2b07 | |||
| 6b3fbe2f90 | |||
| 0c19a3ee76 | |||
| 60aa0a7d3a | |||
| 141484b3e3 | |||
| 77b541475e | |||
| 4ca5e4b3c2 | |||
| 124f0506e2 | |||
| aa56bc219e | |||
| 7264dcb1f7 | |||
| ffeb61df3e | |||
| 5d0aa9b57a | |||
| e98ee1f874 | |||
| c145431dc9 | |||
| aa3e290391 | |||
| 4a36b6b264 | |||
| f3875302d7 | |||
| 21a40ba9f5 | |||
| e7fd13c649 | |||
| fc066e139d | |||
| f3bd94f646 | |||
| 6cd21296a9 | |||
| ba3a8a293d | |||
| aa2776a45e | |||
| 1e6f17257c | |||
| e47f42eb49 | |||
| 9e71954348 | |||
| c3a5d1470c | |||
| 8b0b881ec1 | |||
| 6060ebeb4e | |||
| eec45d26b7 | |||
| 8b4dcd8335 | |||
| 2d728fb791 | |||
| dc885b9903 | |||
| 8b0fb81d7b | |||
| f04b0d0efd | |||
| 06c50a9231 |
4
.github/workflows/pre-commit.yml
vendored
4
.github/workflows/pre-commit.yml
vendored
@ -60,7 +60,7 @@ jobs:
|
||||
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
|
||||
if: matrix.group == 'flutter'
|
||||
with:
|
||||
flutter-version: 3.47.7
|
||||
flutter-version: 3.47.9
|
||||
working-directory: stronglift_replacement/workout_app
|
||||
# analyze needs resolved packages (.dart_tool); the hook runs analyze
|
||||
# only, so fetch deps here first.
|
||||
@ -83,7 +83,7 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
if: matrix.group == 'rest'
|
||||
with:
|
||||
node-version: "24.22.1"
|
||||
node-version: "26.13.0"
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
- name: Install status-UI packages
|
||||
|
||||
8
.github/workflows/python-tests.yml
vendored
8
.github/workflows/python-tests.yml
vendored
@ -58,11 +58,11 @@ jobs:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: requirements.txt
|
||||
|
||||
# No apt step: the runner image already ships xvfb-run, and
|
||||
# No apt step: the runner image already ships Xvfb, and
|
||||
# setup-python's interpreter has its own tkinter (python3-tk is for the
|
||||
# system python, which this job never runs). The apt-get update +
|
||||
# install cost ~35 s. If either ever goes missing the check does not
|
||||
# skip -- _xvfb_reexec returns 1 without xvfb-run, and the import fails
|
||||
# skip -- _xvfb_reexec returns 1 without Xvfb, and the import fails
|
||||
# without tkinter.
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@ -99,7 +99,7 @@ jobs:
|
||||
# cache cost ~38 s to restore even on a hit.
|
||||
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
|
||||
with:
|
||||
flutter-version: 3.47.7
|
||||
flutter-version: 3.47.9
|
||||
working-directory: stronglift_replacement/workout_app
|
||||
|
||||
- name: flutter test --coverage, bucket ${{ matrix.shard }}
|
||||
@ -136,7 +136,7 @@ jobs:
|
||||
package_json_file: web/package.json
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.22.1
|
||||
node-version: 26.13.0
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
|
||||
2
.github/workflows/release-apk.yml
vendored
2
.github/workflows/release-apk.yml
vendored
@ -60,7 +60,7 @@ jobs:
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: 3.47.7
|
||||
flutter-version: 3.47.9
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -38,6 +38,7 @@ screen_locker/early_bird_pending.json
|
||||
# Web status UI (web/) build and tooling output.
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/.astro/
|
||||
web/coverage/
|
||||
web/node_modules
|
||||
web/dist
|
||||
|
||||
@ -8,13 +8,18 @@ default_language_version:
|
||||
default_stages: [pre-commit]
|
||||
fail_fast: false
|
||||
|
||||
default_install_hook_types: [pre-commit, pre-push, post-commit, post-merge, post-rewrite]
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
# run_tcx holds byte-exact RunnerUp exports; the golden test compares the
|
||||
# encoder's output with them, so the fixers must never rewrite them.
|
||||
- id: trailing-whitespace
|
||||
args: [--markdown-linebreak-ext=md]
|
||||
exclude: ^screen_locker/tests/fixtures/run_tcx/
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^screen_locker/tests/fixtures/run_tcx/
|
||||
- id: check-yaml
|
||||
# pnpm-lock.yaml is a two-document stream by design.
|
||||
exclude: ^web/pnpm-lock\.yaml$
|
||||
@ -106,7 +111,10 @@ repos:
|
||||
- types-requests>=2.31.0
|
||||
|
||||
- repo: https://github.com/pylint-dev/pylint
|
||||
rev: v4.0.8
|
||||
# Same release as requirements.txt. No --jobs here: pyproject.toml holds
|
||||
# it at 1, because parallel pylint pickles through dill, which crashes on
|
||||
# Python 3.15 (see the comment on `jobs` there).
|
||||
rev: v4.1.3
|
||||
hooks:
|
||||
# Two hooks, because the three checks below are not defects in a test
|
||||
# suite -- they are how pytest and unit-testing private mixins work --
|
||||
@ -119,36 +127,36 @@ repos:
|
||||
# duplicate-code near-identical `with patch(...)` preambles; a test
|
||||
# should read top-to-bottom without chasing a fixture.
|
||||
# Splitting also removes the old failure mode where the score depended on
|
||||
# what else happened to be staged alongside. See
|
||||
# todo_fix_pylint_10_prompt.md for raising both to a hard 10.00.
|
||||
# what else happened to be staged alongside. Both are a hard 10.00.
|
||||
- id: pylint
|
||||
name: pylint (source)
|
||||
name: pylint (source, 10.00)
|
||||
alias: pylint-source
|
||||
exclude: ^screen_locker/tests/
|
||||
args:
|
||||
- --rcfile=pyproject.toml
|
||||
- --fail-under=8.0
|
||||
- --jobs=4
|
||||
- --fail-under=10
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
# Kept in step with pyproject.toml: pip refuses two different
|
||||
# direct URLs for one distribution, so any pin that drifts from
|
||||
# the manifest becomes an unresolvable hook environment rather
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.16.0#subdirectory=earned_time
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
|
||||
# Same pin as workout_app/tools/render_finish_chime.sh installs.
|
||||
- music_theory @ git+https://github.com/kuhyx/utils@music-theory-v0.1.0#subdirectory=music_theory
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
- id: pylint
|
||||
name: pylint (tests)
|
||||
name: pylint (tests, 10.00)
|
||||
alias: pylint-tests
|
||||
files: ^screen_locker/tests/
|
||||
args:
|
||||
- --rcfile=pyproject.toml
|
||||
- --fail-under=8.0
|
||||
- --jobs=4
|
||||
- --fail-under=10
|
||||
- --disable=protected-access,unused-argument,duplicate-code
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
@ -156,9 +164,9 @@ repos:
|
||||
# direct URLs for one distribution, so any pin that drifts from
|
||||
# the manifest becomes an unresolvable hook environment rather
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.16.0#subdirectory=earned_time
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
|
||||
- mcp>=2,<3
|
||||
@ -182,6 +190,9 @@ repos:
|
||||
# The CI mirror, one step per hook so each call stays under the 60 s
|
||||
# action cap. Order matters: ci-venv (re)builds .ci-mirror-venv from
|
||||
# requirements.txt when it changed, so ci-pytest only reuses it.
|
||||
# `pre-commit run --all-files` is split into four ci-precommit-* steps
|
||||
# (each well under the cap); together they run every default-stage hook
|
||||
# exactly once -- see run_precommit_group in scripts/ci_mirror.sh.
|
||||
- id: ci-venv
|
||||
name: CI mirror (clean requirements.txt venv)
|
||||
entry: bash scripts/ci_mirror.sh venv
|
||||
@ -190,9 +201,33 @@ repos:
|
||||
always_run: true
|
||||
require_serial: true
|
||||
stages: [manual]
|
||||
- id: ci-precommit
|
||||
name: CI mirror (pre-commit --all-files)
|
||||
entry: bash scripts/ci_mirror.sh precommit
|
||||
- id: ci-precommit-pylint-source
|
||||
name: "CI mirror (pre-commit --all-files: pylint source)"
|
||||
entry: bash scripts/ci_mirror.sh precommit pylint-source
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
require_serial: true
|
||||
stages: [manual]
|
||||
- id: ci-precommit-pylint-tests
|
||||
name: "CI mirror (pre-commit --all-files: pylint tests)"
|
||||
entry: bash scripts/ci_mirror.sh precommit pylint-tests
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
require_serial: true
|
||||
stages: [manual]
|
||||
- id: ci-precommit-flutter-web
|
||||
name: "CI mirror (pre-commit --all-files: flutter analyze + web lint/typecheck)"
|
||||
entry: bash scripts/ci_mirror.sh precommit flutter-web
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
require_serial: true
|
||||
stages: [manual]
|
||||
- id: ci-precommit-rest
|
||||
name: "CI mirror (pre-commit --all-files: every other hook)"
|
||||
entry: bash scripts/ci_mirror.sh precommit rest
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
@ -239,11 +274,13 @@ repos:
|
||||
# the gate it was being refactored to satisfy. ts/tsx joined on
|
||||
# 2026-08-29 with web/, for exactly the same reason: a language absent
|
||||
# from this list is a directory the cap silently does not apply to.
|
||||
types_or: [python, shell, dart, ts, tsx]
|
||||
# c/c++ and markdown joined on 2026-11-01 (the Linux runner had grown
|
||||
# to 258; prose is under the same cap per the 250-line rule).
|
||||
types_or: [python, shell, dart, ts, tsx, c, c++, markdown]
|
||||
exclude: '^web/(node_modules|dist|coverage)/'
|
||||
- id: no-silent-failures
|
||||
name: Block silent failures (swallowed exceptions and silent sys.exit(0) must say why)
|
||||
entry: python3 scripts/check_silent_failures.py
|
||||
entry: python3 -m scripts.check_silent_failures
|
||||
language: system
|
||||
types: [python]
|
||||
- id: no-silent-failures-dart
|
||||
@ -251,7 +288,7 @@ repos:
|
||||
# single Dart file — in the app where a swallowed sync error costs the
|
||||
# user a workout with nothing anywhere saying so.
|
||||
name: Block silent failures in Dart (swallowed errors must log why)
|
||||
entry: python3 scripts/check_silent_failures_dart.py
|
||||
entry: python3 -m scripts.check_silent_failures_dart
|
||||
language: system
|
||||
types: [dart]
|
||||
- id: screens-fit
|
||||
@ -271,12 +308,46 @@ repos:
|
||||
language: system
|
||||
files: '^stronglift_replacement/workout_app/.*\.(dart|yaml)$'
|
||||
pass_filenames: false
|
||||
- id: flutter-test-coverage
|
||||
name: flutter test --coverage 100% (workout app)
|
||||
entry: bash scripts/check_flutter_coverage.sh
|
||||
- id: flutter-test-coverage-shard-0
|
||||
name: flutter test bucket 0/4 (workout app, local gate)
|
||||
entry: bash scripts/check_flutter_coverage.sh --gate-shard 0
|
||||
language: system
|
||||
files: '^stronglift_replacement/workout_app/.*\.dart$'
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
require_serial: true
|
||||
stages: [manual]
|
||||
- id: flutter-test-coverage-shard-1
|
||||
name: flutter test bucket 1/4 (workout app, local gate)
|
||||
entry: bash scripts/check_flutter_coverage.sh --gate-shard 1
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
require_serial: true
|
||||
stages: [manual]
|
||||
- id: flutter-test-coverage-shard-2
|
||||
name: flutter test bucket 2/4 (workout app, local gate)
|
||||
entry: bash scripts/check_flutter_coverage.sh --gate-shard 2
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
require_serial: true
|
||||
stages: [manual]
|
||||
- id: flutter-test-coverage-shard-3
|
||||
name: flutter test bucket 3/4 (workout app, local gate)
|
||||
entry: bash scripts/check_flutter_coverage.sh --gate-shard 3
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
require_serial: true
|
||||
stages: [manual]
|
||||
# Fails unless every shard above passed on THIS tree, then applies the
|
||||
# unchanged 100% bar and every-lib-file guard to their union.
|
||||
- id: flutter-test-coverage-merge
|
||||
name: flutter coverage 100% (union of the 4 buckets)
|
||||
entry: bash scripts/check_flutter_coverage.sh --gate-merge
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
require_serial: true
|
||||
stages: [manual]
|
||||
|
||||
@ -287,14 +358,14 @@ repos:
|
||||
name: eslint (status web UI)
|
||||
entry: pnpm --dir web run lint
|
||||
language: system
|
||||
files: '^web/(src/.*\.(ts|tsx)|eslint\.config\.js|package\.json)$'
|
||||
files: '^web/(src/.*\.(ts|astro)|eslint\.config\.js|package\.json)$'
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
- id: web-typecheck
|
||||
name: tsc --build (status web UI)
|
||||
name: astro check + build (status web UI)
|
||||
entry: pnpm --dir web run build
|
||||
language: system
|
||||
files: '^web/(src/.*\.(ts|tsx)|tsconfig.*\.json|vite\.config\.ts)$'
|
||||
files: '^web/(src/.*\.(ts|astro)|tsconfig\.json|astro\.config\.mjs|vitest\.config\.ts)$'
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
- id: web-coverage
|
||||
@ -316,6 +387,30 @@ repos:
|
||||
language: system
|
||||
files: \.(md|markdown)$
|
||||
|
||||
- id: todo-closer
|
||||
name: close Todo trailer tasks
|
||||
entry: scripts/todo_hooks.sh closer
|
||||
language: system
|
||||
stages: [post-commit]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
- id: todo-import-pr-merge
|
||||
name: import PR Todo sections (pull)
|
||||
entry: scripts/todo_hooks.sh import-pr post-merge
|
||||
language: system
|
||||
stages: [post-merge]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
- id: todo-import-pr-rewrite
|
||||
name: import PR Todo sections (pull --rebase)
|
||||
entry: scripts/todo_hooks.sh import-pr post-rewrite
|
||||
language: system
|
||||
stages: [post-rewrite]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
# Every dependency on its ecosystem's newest STABLE release, exact-pinned
|
||||
# so the question is mechanically answerable at all.
|
||||
#
|
||||
|
||||
20
README.md
20
README.md
@ -48,6 +48,26 @@ so a run finished after 20:00 pushes the hour later without the phone being
|
||||
plugged in; the per-minute shutdown check then lifts the night lockdown on its
|
||||
own once the hour is past "now" (digital-wellbeing's check script).
|
||||
|
||||
Runs and walks are credited by ONE rule, whatever the TCX sport or the
|
||||
`_Walking` file-name suffix (RunnerUp's files and the workout app's alike): a
|
||||
day's activities are summed, and the day counts once their distance reaches
|
||||
3.5 km (5% GPS tolerance) OR their *moving* time reaches the shared duration
|
||||
bar (30 min minus the hidden leeway). Both are summed over the track cut into
|
||||
windows of at least 10 s, each judged by its average speed: faster than 25 km/h
|
||||
(a vehicle or a GPS jump) counts neither time nor distance; moving time needs
|
||||
0.5 m/s or more (1 m/s across a signal dropout longer than 30 s, so a pause
|
||||
earns nothing but a run through a dropout keeps its time). Files without
|
||||
trackpoints earn nothing (the lap totals are never used). The reward is the usual one
|
||||
workout credit per day (`runnerup_verified:<date>`); the log's `activity`
|
||||
field is display only. Code: `screen_locker/_walk_tcx.py`.
|
||||
|
||||
**Retiring RunnerUp.** A week after the PC first credits a run recorded by the
|
||||
workout app (TCX author `kuhy workout_app`), `runnerup-retire.timer` (daily, retries
|
||||
until the phone is attached) backs `/sdcard/Documents/RunnerUp` up to
|
||||
`~/data/cloud/RunnerUp/runnerup_phone_backup_<date>/` and uninstalls `org.runnerup.free`,
|
||||
once the app's `.workout_app_import.json` matches `processed/`; no app run, no uninstall.
|
||||
`python3 -m screen_locker.runnerup_retire --status` / `--dry-run` change nothing.
|
||||
|
||||
## MCP server (Claude Code integration)
|
||||
|
||||
screen-locker exposes a **read-only** MCP server (`screen_locker._mcp`) so
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
# Cap every live shutdown bonus at the day's ceiling, not 24:00 / 23:00
|
||||
|
||||
Carried over from the 2026-10-10 tutor-cutover handoff (item 1a, optional).
|
||||
|
||||
## what
|
||||
The live bonus paths clamp at 24:00 (`_shutdown.py` `_MIDNIGHT`, ~:152) and
|
||||
then at `adjust_shutdown_schedule.sh`'s fixed `RESTORE_CEILING=1380` (23:00).
|
||||
If `WAKE_MINUTES` ever moves the earned_time ceiling below 23:00, a bonus could
|
||||
push shutdown past the real ceiling. Cap them at
|
||||
`earned_time.shutdown_ceiling_for(day)` instead (already wrapped in
|
||||
`_earned.py` ~:167).
|
||||
|
||||
## where
|
||||
`screen_locker/_shutdown.py`, `screen_locker/adjust_shutdown_schedule.sh`,
|
||||
`scripts/_preview_sim.py` (`HELPER_CEILING` mirrors `RESTORE_CEILING`).
|
||||
|
||||
## must
|
||||
- The 168-case "do every task => 23:00" invariant test (fc54534) stays green.
|
||||
- must not: loosen anything — the shell ceiling remains a hard upper bound.
|
||||
|
||||
## done
|
||||
A test that sets a ceiling below 23:00 shows no bonus path exceeding it.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -1,95 +0,0 @@
|
||||
# Refactor TODO — enforce the 250-line file cap
|
||||
|
||||
> **This file is a ready-to-use prompt.** Paste it to Claude, or open this repo
|
||||
> and say "do refactor_claude_todo". It is self-contained: everything needed to
|
||||
> execute is below. Generated 2026-08-14 from a measured survey of every repo.
|
||||
|
||||
## Goal
|
||||
|
||||
Every file in this repo must be **at most 250 lines** — source, tests, and
|
||||
prose (`.md`/`.txt`/`.rst`/`.tex`) alike — and must **stay** that way forever,
|
||||
enforced by a gate that fails the commit, not by a note anyone can ignore.
|
||||
|
||||
Why: a file that cannot be read in one piece forces re-reads and partial edits,
|
||||
which is the single largest avoidable cost in an LLM-assisted workflow. Aim by
|
||||
churn, not size alone — refactoring pays where code is read and changed often
|
||||
(Fowler, _refactoring economic benefit_).
|
||||
|
||||
## Scope in this repo
|
||||
|
||||
<!-- BEGIN GENERATED VIOLATIONS -->
|
||||
|
||||
- **10 files** currently exceed 250 lines.
|
||||
- **1,828 lines** over the cap in total (the work left to do); longest file is **740** lines.
|
||||
|
||||
ROI = lines x commits in the last year. Work top-down; a long file nobody edits
|
||||
has near-zero payoff and should not be first.
|
||||
|
||||
| lines | commits/yr | kind | file |
|
||||
| ----: | ---------: | :--- | :--- |
|
||||
| 503 | 13 | code | `stronglift_replacement/workout_app/lib/screens/settings_screen.dart` |
|
||||
| 633 | 9 | code | `stronglift_replacement/workout_app/lib/screens/workout_screen.dart` |
|
||||
| 740 | 7 | code | `stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart` |
|
||||
| 322 | 10 | code | `stronglift_replacement/workout_app/lib/services/workout_sync_service.dart` |
|
||||
| 383 | 7 | code | `stronglift_replacement/workout_app/test/screens/home_screen_test.dart` |
|
||||
| 260 | 9 | code | `stronglift_replacement/workout_app/lib/screens/home_screen.dart` |
|
||||
| 386 | 5 | code | `stronglift_replacement/workout_app/test/screens/history_screen_test.dart` |
|
||||
| 362 | 4 | code | `stronglift_replacement/workout_app/test/screens/workout_screen_test.dart` |
|
||||
| 313 | 2 | code | `stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart` |
|
||||
| 426 | 1 | code | `stronglift_replacement/workout_app/test/services/progression_sync_service_test.dart` |
|
||||
|
||||
<!-- END GENERATED VIOLATIONS -->
|
||||
|
||||
|
||||
Exempt (do NOT split these):
|
||||
|
||||
- generated files — `*.g.dart`, `*.freezed.dart`, `*.gr.dart`, `**/l10n/generated/**`,
|
||||
anything with a `GENERATED` header
|
||||
- markup — `.html`, `.css`, `.scss`
|
||||
- data files — `.json`, `.yaml`, `.csv`, wordlists and other data-ish `.txt`
|
||||
(mean line length under 25 chars)
|
||||
|
||||
|
||||
## How to split
|
||||
|
||||
- **Python** — extract cohesive helpers into sibling modules; keep the public
|
||||
API and imports stable.
|
||||
- **Shell** — split into `lib/*.sh` sourced by a thin entry script. Keep
|
||||
`set -euo pipefail` in each.
|
||||
- **Dart / TypeScript** — extract widgets/components into their own files.
|
||||
- **Tests** — split by test-group into sibling files
|
||||
(`foo.test.ts` -> `foo.parsing.test.ts`, `foo.render.test.ts`). Coverage must
|
||||
not drop.
|
||||
- **Docs** — split into topic files under `docs/` with an index. For an
|
||||
oversized `CLAUDE.md`, move detail into referenced docs so the
|
||||
always-loaded part shrinks.
|
||||
|
||||
**Do not** game the cap: no one-lining, no deleting tests, no moving code into
|
||||
an exempt extension, no `# noqa`-style suppressions.
|
||||
|
||||
## Make it permanent (required — this is the point)
|
||||
|
||||
A refactor without a gate silently regrows. Before this task is done:
|
||||
|
||||
1. Wire the shared gate `~/src/utils/scripts/check_file_length.sh` into this repo's
|
||||
`.pre-commit-config.yaml` as a local hook. If the repo has no pre-commit
|
||||
config, add a minimal one.
|
||||
2. The hook checks **files in the commit** (not the whole tree), so unrelated
|
||||
commits never break, and it **fails** — exit 1, not a warning.
|
||||
3. No baseline file and no allowlist. Those are suppressions.
|
||||
4. If this repo has CI (`.github/workflows`), add the same check there so it
|
||||
also fails on push.
|
||||
|
||||
## Done condition
|
||||
|
||||
- `bash ~/src/utils/scripts/check_file_length.sh --all` from this repo root exits 0.
|
||||
- The repo's own test suite and coverage bar are still green.
|
||||
- `pre-commit run --files <changed files>` passes.
|
||||
- A deliberately over-250-line test file, staged, makes `git commit` **fail**.
|
||||
- For a deployed daemon/app: the entry point still actually runs.
|
||||
|
||||
## Verify
|
||||
|
||||
Run the suite, then actually run the locker entry point — this repo deploys from the working tree, so a broken import is a live outage.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -1,178 +0,0 @@
|
||||
# Finish TODO — the last 10 Dart files over 250 lines
|
||||
|
||||
> **This file is a ready-to-use prompt.** Open this repo and say
|
||||
> "do todo_finish_dart_250_prompt". It is self-contained.
|
||||
> Generated 2026-08-15 at the end of a session that took the repo from 41
|
||||
> files over the cap to 10. All 10 remaining are Dart.
|
||||
|
||||
## Goal
|
||||
|
||||
`bash ~/src/utils/scripts/check_file_length.sh --all` from the repo root exits 0.
|
||||
|
||||
Everything else in `refactor_claude_todo.md` still applies (no gaming the cap,
|
||||
no deleting tests, no suppressions). This file only adds what the previous
|
||||
session learned the hard way.
|
||||
|
||||
## Start here (do this first, it costs one command)
|
||||
|
||||
```bash
|
||||
cd ~/src/screen-locker/stronglift_replacement/workout_app
|
||||
env -u GIT_DIR flutter test 2>&1 | tail -1 # MUST print: +402: All tests passed!
|
||||
```
|
||||
|
||||
**402 is the invariant.** Dart has no glob-parametrized test like the Python
|
||||
side's `test_no_popup_widgets`, so the count never legitimately changes. Any
|
||||
other number means a lost, duplicated, or non-compiling test — stop and fix
|
||||
before continuing. A file that fails to *load* shows as
|
||||
`Failing tests: ... loading X` and drops the count, so check the number, not
|
||||
just the absence of red.
|
||||
|
||||
Never use `git stash` (blocked by a hook). To compare against HEAD:
|
||||
`git worktree add --detach /tmp/wt HEAD` … `git worktree remove /tmp/wt`.
|
||||
|
||||
## The 10 files
|
||||
|
||||
| lines | file |
|
||||
| ----: | :--- |
|
||||
| 740 | `test/services/workout_sync_service_test.dart` |
|
||||
| 633 | `lib/screens/workout_screen.dart` |
|
||||
| 503 | `lib/screens/settings_screen.dart` |
|
||||
| 426 | `test/services/progression_sync_service_test.dart` |
|
||||
| 386 | `test/screens/history_screen_test.dart` |
|
||||
| 383 | `test/screens/home_screen_test.dart` |
|
||||
| 362 | `test/screens/workout_screen_test.dart` |
|
||||
| 322 | `lib/services/workout_sync_service.dart` |
|
||||
| 313 | `lib/screens/manual_workout_screen.dart` |
|
||||
| 260 | `lib/screens/home_screen.dart` |
|
||||
|
||||
Re-run `python3 scripts/refresh_refactor_todo.py` for the live set.
|
||||
|
||||
## Part 1 — the 4 test files
|
||||
|
||||
`scripts/split_dart_test.py` works and is committed. Usage:
|
||||
|
||||
```bash
|
||||
python3 scripts/split_dart_test.py <file> <out1>:<boundaryIdx> [<out2>:<idx> ...]
|
||||
```
|
||||
|
||||
Boundary indices are positions in the list of top-level `group(` /
|
||||
`testWidgets(` / `test(` lines. Each output takes `boundaries[idx:next_idx]`;
|
||||
the source keeps everything before the first.
|
||||
|
||||
**Its working envelope — stay inside it:**
|
||||
|
||||
1. **One pass, from the pristine file.** Specify every output up front. Do NOT
|
||||
split an already-split file: indices are recomputed against the new file and
|
||||
the trailer is re-copied, which is how content went missing last time.
|
||||
2. **Zero top-level declarations after `main()`.** Check with:
|
||||
`awk -v m=$(grep -n '^void main(' F | cut -d: -f1) 'NR>m && /^[A-Za-z_]/' F`
|
||||
The splitter copies the trailer, but the detection is unreliable when
|
||||
helpers sit *between* groups.
|
||||
3. **No local function declarations in the preamble.** `history_screen_test.dart`
|
||||
defines `_pump`, `_wrap`, `_seed` as locals inside `main()`; both halves get
|
||||
the copy but the second half's references resolve wrong. This is why that
|
||||
file is still on the list.
|
||||
|
||||
**All 4 remaining files are OUTSIDE the envelope** (that's why they're left):
|
||||
|
||||
- `workout_sync_service_test.dart` — 2 post-`main` declarations
|
||||
(`_manualWorkoutSurvivalTests`, `_cutoverTests`), plus `_fakeFirebase`,
|
||||
`_manual`, `_PutCall` used across the split point.
|
||||
- `home_screen_test.dart` — 4 post-`main` declarations (fake sync classes
|
||||
overriding `syncNow`).
|
||||
- `progression_sync_service_test.dart` — 1 post-`main` declaration.
|
||||
- `history_screen_test.dart` — the local-function preamble above.
|
||||
|
||||
**The fix, and it is its own committed step:** move those shared helpers and
|
||||
fakes into a sibling `test/services/_sync_test_fixtures.dart` (or
|
||||
`test/screens/_home_test_fixtures.dart`) and `import` it — the same shape as
|
||||
`screen_locker/tests/_workout_sync_fixtures.py` on the Python side. Commit
|
||||
that move, verified at 402, *before* splitting. Then the file is inside the
|
||||
envelope and the splitter handles it.
|
||||
|
||||
**One file at a time → verify 402 → commit.** Batching means a wrong count
|
||||
doesn't tell you which file caused it, and the revert tangles.
|
||||
|
||||
## Part 2 — the 6 `lib/` files
|
||||
|
||||
Two patterns are proven and committed; copy them.
|
||||
|
||||
**A. Private widget classes → `part` file.** Used on `history_screen`,
|
||||
`settings_screen`, `home_screen`, `exercise_tile`, `github_mirror_screen`.
|
||||
Dart privacy is library-scoped, so `part` keeps `_Foo` classes private —
|
||||
making them public would trip `public_member_api_docs` and change the API.
|
||||
A `part` also gets no `SF:` entry in lcov, so the coverage gate's
|
||||
"every lib file must appear" guard has nothing new to miss.
|
||||
|
||||
**B. Methods → `extension` in a `part` file.** Used on `storage_service`
|
||||
(5 files), `progression_sync_service` (4), `backup_service`,
|
||||
`workout_sync_service`. Dart can't continue a class body across files, but an
|
||||
extension in a part still reaches `_privateField`.
|
||||
|
||||
### Three hard constraints — each one cost a debugging cycle
|
||||
|
||||
1. **`setState` is `@protected`.** It cannot be called from an extension. Any
|
||||
State method that calls `setState` must stay in the class. This is the main
|
||||
blocker on `home_screen.dart` (260) and `settings_screen.dart` (503).
|
||||
2. **Extension methods dispatch STATICALLY.** A method a test fake overrides
|
||||
must stay in the class. `_FakeSync extends WorkoutSyncService` overrides
|
||||
`pushManual` and `readMergedManualPayloads`; with those in an extension the
|
||||
*real* implementations ran and four manual-workout tests hung on
|
||||
`pumpAndSettle` instead of failing an assertion. Before moving any public
|
||||
method, grep: `grep -rn "extends <ClassName>" -A15 test/`.
|
||||
This is why `workout_sync_service.dart` is stuck at 322 — its public
|
||||
surface is what fakes bind to.
|
||||
3. **Static members need qualifying inside an extension**:
|
||||
`BackupService._baseDir`, `ProgressionSyncService._stateToJson`. The
|
||||
analyzer catches this (`unqualified_reference_to_static_member_of_extended_type`).
|
||||
|
||||
### What's actually left to do in `lib/`
|
||||
|
||||
- `workout_screen.dart` (633) — **not yet attempted.** Check it for private
|
||||
widget classes first (pattern A); that's the cheap win.
|
||||
- `manual_workout_screen.dart` (313) — not yet attempted.
|
||||
- `settings_screen.dart` (503) — widgets already extracted. Going lower needs
|
||||
the ~190-line `build()` broken up: extract the SYNC / OFFLINE BACKUP list
|
||||
sections into private `StatelessWidget`s taking what they read as
|
||||
constructor params (they never call `setState`, so they're eligible). A
|
||||
previous attempt to slice them out as a raw widget-list getter produced
|
||||
unbalanced brackets — build the widget class properly, don't slice text.
|
||||
- `home_screen.dart` (260, only 10 over) — same shape, one cohesive `build()`.
|
||||
- `workout_sync_service.dart` (322) — see constraint 2. Only private helpers
|
||||
can move; getting under 250 means changing the public surface, which is a
|
||||
design decision, not a move. **Ask the user before doing that.**
|
||||
|
||||
## Verify (all of it, every batch)
|
||||
|
||||
```bash
|
||||
cd ~/src/screen-locker/stronglift_replacement/workout_app
|
||||
env -u GIT_DIR dart analyze lib/ # must be "No issues found!"
|
||||
env -u GIT_DIR flutter test 2>&1 | tail -1 # must be +402
|
||||
cd ~/src/screen-locker && pre-commit run # includes the 100% Flutter coverage gate
|
||||
```
|
||||
|
||||
`dart analyze` is a **no-op on test files** — `analysis_options.yaml` excludes
|
||||
`test/**`. For those, the test count and the compile it implies are the only
|
||||
signal.
|
||||
|
||||
## Done condition
|
||||
|
||||
- `bash ~/src/utils/scripts/check_file_length.sh --all` exits 0.
|
||||
- 402 Dart tests pass; `flutter` coverage still 100%.
|
||||
- `pre-commit run --all-files` passes.
|
||||
- The app still runs on the phone: use the `phone-deploy` skill
|
||||
(`bash ~/.claude/scripts/phone_deploy.sh <app-dir> --shot <path>`), then
|
||||
**read the screenshot yourself** and confirm real progression data renders —
|
||||
weights and "Next: Workout A/B", not seeded defaults. That is what proves
|
||||
the `storage_service` part-extensions still resolve.
|
||||
|
||||
## Environment note (not your bug)
|
||||
|
||||
`python3 -m scripts.verify_lock_popup_safety` fails locally with
|
||||
`RecoveryLoop.__init__() takes 3 positional arguments but 6 were given`.
|
||||
The code matches the pinned gatelock 0.4.0; this machine has 0.5.0 installed.
|
||||
Resync with `pip install -r requirements.txt`. **Do not "fix" the code to
|
||||
match the installed version** — that would break CI. See the comment in
|
||||
`scripts/_popup_form_check.py`.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -1,143 +0,0 @@
|
||||
# Fix TODO — make pylint a perfect 10.00 gate
|
||||
|
||||
> **This file is a ready-to-use prompt.** Paste it to Claude, or open this repo
|
||||
> and say "do todo_fix_pylint_10_prompt". It is self-contained: everything
|
||||
> needed to execute is below. Generated 2026-08-15 from a measured run.
|
||||
|
||||
## Goal
|
||||
|
||||
`pylint` must score **10.00/10** on this repo, enforced by the pre-commit hook
|
||||
at `--fail-under=10` — not the current `--fail-under=8.0`, which lets real
|
||||
findings through as long as enough clean files dilute them.
|
||||
|
||||
Why the current bar is wrong: pylint's hook scores the **staged batch** as one
|
||||
number. A commit touching many test files fails while the identical code
|
||||
committed alongside a few clean source files passes. The score is a property of
|
||||
*which files you staged*, not of the code. A 10.00 bar removes that entirely —
|
||||
zero findings is zero findings regardless of batching.
|
||||
|
||||
## Measured starting point (2026-08-15)
|
||||
|
||||
Whole repo (`screen_locker/` + `scripts/`): **8.76/10**.
|
||||
|
||||
Findings by category, highest first:
|
||||
|
||||
| count | check | fixable? |
|
||||
| ----: | :--- | :--- |
|
||||
| 473 | `protected-access` (W0212) | no — test-inherent |
|
||||
| 369 | `unused-argument` (W0613) | no — test-inherent |
|
||||
| 245 | `missing-function-docstring` (C0116) | **yes** |
|
||||
| 238 | `suppressed-message` (I0020) | disappears with W0212/W0613 |
|
||||
| 73 | `duplicate-code` (R0801) | no — test-inherent |
|
||||
| 66 | `use-implicit-booleaness-not-comparison-to-zero` (C1805) | **yes** |
|
||||
| 27 | `import-outside-toplevel` (C0415) | **yes**, mostly |
|
||||
| 25 | `missing-class-docstring` (C0115) | **yes** |
|
||||
| 25 | `locally-disabled` (I0011) | disappears with W0212/W0613 |
|
||||
| 24 | `use-implicit-booleaness-not-comparison` (C1803) | **yes** |
|
||||
| 10 | `useless-suppression` (I0021) | **yes** — delete the dead disables |
|
||||
| ~40 | long tail (see "Long tail" below) | **yes** |
|
||||
|
||||
## The decision already taken
|
||||
|
||||
Three checks are **disabled for test paths only**, because they are not defects
|
||||
— they are how pytest works, and fixing them would mean rewriting ~840 call
|
||||
sites and making production APIs public to suit tests:
|
||||
|
||||
- **`protected-access` (W0212)** — tests drive private mixin methods directly
|
||||
(`locker._verify_phone_workout()`). This is deliberate: those methods *are*
|
||||
the unit under test. Ruff already exempts the same thing in tests via
|
||||
`SLF001` in `pyproject.toml`'s per-file-ignores, so pylint disagreeing with
|
||||
ruff is just noise.
|
||||
- **`unused-argument` (W0613)** — pytest fixtures such as `mock_tk` and
|
||||
`mock_sys_exit` must be *requested* in the signature to take effect, but are
|
||||
never referenced in the body. Consuming them with a dummy `assert` to satisfy
|
||||
a linter is strictly worse code.
|
||||
- **`duplicate-code` (R0801)** — near-identical `with patch(...)` preambles
|
||||
across sibling test modules. Deduplicating them into helpers hurts: a test
|
||||
should read top-to-bottom without chasing a shared fixture.
|
||||
|
||||
Everything else gets **fixed for real**. No `# pylint: disable=` lines, no
|
||||
per-line suppressions — this repo bans `# noqa` / `# type: ignore` repo-wide and
|
||||
the same spirit applies here.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Scope the three disables to tests only.** They must NOT apply to
|
||||
`screen_locker/*.py` or `scripts/*.py`. pylint has no per-path `disable` in
|
||||
`pyproject.toml`, so use one of:
|
||||
- a `screen_locker/tests/.pylintrc` that inherits and adds the disables
|
||||
(pylint picks the nearest rcfile per directory), or
|
||||
- two pre-commit hooks (`pylint-src` with `exclude: ^screen_locker/tests/`,
|
||||
`pylint-tests` with `files: ^screen_locker/tests/`), each `--fail-under=10`.
|
||||
|
||||
Verify the scoping works: introduce a deliberate `protected-access` in a
|
||||
*source* file and confirm it still fails.
|
||||
|
||||
2. **Fix `missing-function-docstring` / `missing-class-docstring` (270).**
|
||||
Docstrings must say what the test *pins down*, not restate its name.
|
||||
Good: `"""An unsigned entry is rejected once a key is available."""`
|
||||
Bad: `"""Test rejected when key available."""`
|
||||
A generator that drafts from the test name lives at
|
||||
`/tmp/.../scratchpad/add_docstrings.py` in the session that wrote this file;
|
||||
it is refactor tooling, not a deliverable — rewrite it if it is gone. Every
|
||||
generated line still needs reading: the draft is a starting point.
|
||||
|
||||
3. **Fix the booleanness checks (94).** `assert x == []` → `assert not x`,
|
||||
`assert n == 0` → `assert not n`, `assert s == ""` → `assert not s`.
|
||||
Careful: only where the value really is a sequence/int/str. `is False` /
|
||||
`is True` assertions on tri-state returns must NOT be collapsed.
|
||||
|
||||
4. **Fix `import-outside-toplevel` (27).** Hoist each to module scope unless it
|
||||
exists to break an import cycle — where it does, that is a real constraint;
|
||||
restructure the modules so the cycle is gone, or leave it and note why.
|
||||
|
||||
5. **Delete `useless-suppression` (10)** — dead `# pylint: disable=` comments
|
||||
that no longer suppress anything.
|
||||
|
||||
6. **Long tail (~40).** `too-many-instance-attributes`, `too-many-arguments`,
|
||||
`too-many-return-statements`, `unspecified-encoding`, `line-too-long`,
|
||||
`comparison-with-callable`, `superfluous-parens`,
|
||||
`pointless-string-statement`, `reimported`, `consider-using-with`,
|
||||
`unreachable`. Fix each on its merits.
|
||||
- `unspecified-encoding` (3) is a genuine latent bug — add `encoding="utf-8"`.
|
||||
- `comparison-with-callable` (3) is often a real bug (comparing a function
|
||||
instead of calling it). Read each one carefully.
|
||||
- `unreachable` (4) is `sys.exit(0)` followed by `return` in
|
||||
`_startup_checks.py` and elsewhere. Those `return`s are **load-bearing
|
||||
under test**: `conftest.py` patches `sys.exit` to a no-op, so without the
|
||||
`return` execution continues into the next branch. Do not delete them
|
||||
blindly — restructure so the intent is expressed without dead code, or
|
||||
leave them and record the reason.
|
||||
- `too-many-ancestors` (1) is `ScreenLocker`'s 19 mixins. Inherent to the
|
||||
design; raise `max-parents` in config rather than collapsing mixins.
|
||||
|
||||
7. **Flip the gate.** Set `--fail-under=10` in `.pre-commit-config.yaml` (both
|
||||
hooks if you split them) and update the hook `name:` to say 10.
|
||||
|
||||
## Done condition
|
||||
|
||||
- `pylint --rcfile=pyproject.toml screen_locker/ scripts/` prints
|
||||
**`rated at 10.00/10`**.
|
||||
- `pre-commit run --all-files` passes.
|
||||
- A deliberately-introduced pylint finding in a **source** file makes
|
||||
`git commit` fail (proves the gate is live and correctly scoped).
|
||||
- `python -m pytest` still green at `fail_under = 100` branch coverage.
|
||||
- No `# pylint: disable=` added anywhere as part of this work.
|
||||
|
||||
## Verify
|
||||
|
||||
Run the suite, then actually run the locker entry point —
|
||||
`python -m screen_locker.screen_lock --status` — because this repo deploys from
|
||||
the working tree, so a broken import is a live outage. Hoisting imports (step 4)
|
||||
is exactly the change that can cause one.
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not lower `--fail-under` below 10 once it is raised.
|
||||
- Do not add per-line `# pylint: disable=` comments to reach the number.
|
||||
- Do not disable W0212/W0613/R0801 globally — tests only. A source file calling
|
||||
another class's private attribute is a real finding.
|
||||
- Do not touch `refactor_claude_todo.md`'s 250-line work; that is a separate,
|
||||
in-progress task with its own prompt file.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -1,126 +0,0 @@
|
||||
# Task: replace blind coordinate tapping with element-targeted Android UI automation
|
||||
|
||||
> **STATUS 2026-08-10: BUILT.** The tool now exists at
|
||||
> `~/src/testsAndMisc/python_pkg/android_ui/` (see its README). It covers `dump` /
|
||||
> `find` / `tap` / `type` / `wait` / `focus` with verified typing, ambiguity as
|
||||
> an error, keyboard detection, and partial-tree retries — all exercised
|
||||
> against the physical Pixel 6a. What remains from the spec below is the
|
||||
> optional MCP wrapper, `scroll_to()`, and rolling it out to `~/src/todo` and
|
||||
> `~/src/dufs-cloud/app`. Everything after this line is the original brief, kept
|
||||
> because it records WHY each guarantee exists.
|
||||
|
||||
## what
|
||||
|
||||
Every Flutter/Android app in these repos is currently driven by an agent the
|
||||
same bad way: screenshot -> read pixel positions by eye -> `adb shell input tap
|
||||
<x> <y>`. Replace that with a reusable tool that finds elements by *identity*
|
||||
(text, content-desc, resource-id) and taps their real bounds, and that can
|
||||
report what is on screen as structured text instead of an image.
|
||||
|
||||
## why this is not a nicety
|
||||
|
||||
Observed in one session (2026-08-10, workout_app Firebase verification):
|
||||
|
||||
- **Silent mis-taps.** A tap at `(540, 1705)` intended for the password field
|
||||
landed with the field unfocused. The typed password went nowhere. Nothing
|
||||
errored — the next screenshot just showed an empty box. A no-op and a hit are
|
||||
indistinguishable without dumping the tree and diffing.
|
||||
- **Layout shift invalidates every coordinate.** Opening the soft keyboard moved
|
||||
the email field from y=1572 to y=1319. Coordinates captured one step earlier
|
||||
were already wrong.
|
||||
- **Screenshot scaling is a manual multiply.** The harness renders 1080x2400 at
|
||||
900x2000, so every read coordinate needs `* 1.20`. One forgotten multiply is
|
||||
an off-screen tap.
|
||||
- **Screenshots are expensive.** A full-page PNG per step burns far more context
|
||||
than the equivalent element list, and can't be grepped or asserted on.
|
||||
|
||||
`uiautomator dump` already solves this and is on every device — no new
|
||||
dependency:
|
||||
|
||||
```bash
|
||||
adb shell uiautomator dump /sdcard/ui.xml
|
||||
adb pull /sdcard/ui.xml
|
||||
# -> <node text="Connect Firebase" class="android.widget.Button"
|
||||
# bounds="[32,1808][1048,1878]" .../>
|
||||
```
|
||||
|
||||
This is how the same session recovered: dumping the tree found both `EditText`
|
||||
nodes by class and gave exact centers, and the retry worked first time.
|
||||
|
||||
## where
|
||||
|
||||
New shared tool, not a per-repo copy. Two options, pick one:
|
||||
|
||||
- `~/src/testsAndMisc/python_pkg/android_ui/` — a CLI (`android-ui find "Connect
|
||||
Firebase"`, `android-ui tap "Connect Firebase"`, `android-ui dump`), matching
|
||||
how `app_icons` is already shared across repos.
|
||||
- An MCP server exposing the same as tools, which is what makes it available to
|
||||
the agent without shelling out. Preferred if MCP is acceptable — the user
|
||||
asked for "some tool ... (mcp?) which also translates to UI".
|
||||
|
||||
Consumers (every repo with an Android surface):
|
||||
- `~/src/screen-locker/stronglift_replacement/workout_app`
|
||||
- `~/src/todo`
|
||||
- `~/src/dufs-cloud/app`
|
||||
- any future Flutter app (this should be part of the app scaffold)
|
||||
|
||||
## must
|
||||
|
||||
- `find(query)` -> matching elements with class, text/content-desc, bounds,
|
||||
center, and enabled/focused state. Match on text, content-desc, or
|
||||
resource-id; substring and exact.
|
||||
- `tap(query)` -> resolves the query, taps the center of the *current* bounds
|
||||
(re-dump before every tap; never reuse a stale coordinate), and **fails
|
||||
loudly with a non-zero exit when the query matches zero or >1 elements**.
|
||||
Ambiguity must be an error, not a silent first-match.
|
||||
- `type(query, text)` -> focus the field, type, then **verify the field's text
|
||||
actually changed** and fail if not. This is the specific bug that cost this
|
||||
session a round trip.
|
||||
- `dump()` -> flat, greppable text list of visible elements. This replaces the
|
||||
screenshot for most steps.
|
||||
- Waits: `wait_for(query, timeout)` that polls the tree, so no more blind
|
||||
`sleep 14`.
|
||||
- must not: require root, an emulator, a running Flutter debug connection, or
|
||||
any change to app source. It must work against a plain release APK on a
|
||||
physical device over adb.
|
||||
- optional: `flutter drive` / integration_test as a second backend when a debug
|
||||
build IS available — richer (widget keys, no dump latency), but it cannot
|
||||
drive a release APK, so it can't be the only path.
|
||||
- optional: screenshot fallback for genuinely visual checks (theming, icon
|
||||
rendering) — where a picture is the point, not a workaround.
|
||||
|
||||
## done
|
||||
|
||||
A verification script that drives workout_app's "connect Firebase" flow
|
||||
end-to-end with **zero hardcoded pixel coordinates** in it, and:
|
||||
|
||||
- passes on the current layout;
|
||||
- still passes after the SYNC and OFFLINE BACKUP sections are reordered in
|
||||
`settings_screen.dart` (the test that coordinate-based automation fails);
|
||||
- exits non-zero, naming the query, when an element is missing.
|
||||
|
||||
## verify
|
||||
|
||||
On the physical Pixel 6a over adb, against an installed release APK — not an
|
||||
emulator and not a debug-only Flutter driver session.
|
||||
|
||||
## read first
|
||||
|
||||
- This session's working recovery: `uiautomator dump` + the bounds-parsing
|
||||
python in the screen-locker scratchpad (`ui.xml`, `ui2.xml`).
|
||||
- `~/src/screen-locker/CLAUDE.md` — "You are NOT done until you install the new
|
||||
version on the phone itself", which is why agent-driven UI is on the critical
|
||||
path for every task in this repo.
|
||||
- Note `uiautomator dump` returns an empty/partial tree while an animation is
|
||||
in flight; retry-with-backoff belongs in the tool, not in each caller.
|
||||
- **Observed 2026-08-10, must be handled:** while a Flutter `TextField` holds
|
||||
focus, the dump returns ONLY the `EditText` nodes — the rest of the tree
|
||||
(including the button you are about to tap) is missing, and it stays missing
|
||||
across retries, so a naive retry loop never converges. Worse, `KEYCODE_BACK`
|
||||
to dismiss the keyboard is treated by Flutter as a route pop: it navigated
|
||||
out of Settings and discarded the typed credentials, twice. The tool must
|
||||
unfocus without popping the route (tap dead space, or `dumpsys input_method`
|
||||
to check `mInputShown` first) and must distinguish "tree is partial" from
|
||||
"element is genuinely absent" rather than reporting a false negative.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -17,6 +17,8 @@ BONUS_SERVICE_NAME="earner-bonus.service"
|
||||
BONUS_PATH_NAME="earner-bonus.path"
|
||||
RUNNERUP_WATCH_NAME="runnerup-watch.service"
|
||||
WORKOUT_POKE_NAME="workout-poke.service"
|
||||
RETIRE_SERVICE_NAME="runnerup-retire.service"
|
||||
RETIRE_TIMER_NAME="runnerup-retire.timer"
|
||||
|
||||
# Runtime dependencies. screen_lock.py imports tkinter at module scope. On Arch
|
||||
# the tkinter MODULE ships inside the `python` package, but the shared library
|
||||
@ -147,6 +149,12 @@ cp "$SCRIPT_DIR/$RUNNERUP_WATCH_NAME" "$USER_SERVICE_DIR/$RUNNERUP_WATCH_NAME"
|
||||
bash "$SCRIPT_DIR/scripts/workout_poke_keygen.sh"
|
||||
cp "$SCRIPT_DIR/$WORKOUT_POKE_NAME" "$USER_SERVICE_DIR/$WORKOUT_POKE_NAME"
|
||||
|
||||
# The RunnerUp retirement: a week after the workout app's first credited run
|
||||
# it backs the old app's exports up and uninstalls it from the phone. A daily
|
||||
# timer, because the phone is not always attached; a no-op until then.
|
||||
cp "$SCRIPT_DIR/$RETIRE_SERVICE_NAME" "$USER_SERVICE_DIR/$RETIRE_SERVICE_NAME"
|
||||
cp "$SCRIPT_DIR/$RETIRE_TIMER_NAME" "$USER_SERVICE_DIR/$RETIRE_TIMER_NAME"
|
||||
|
||||
# Update paths in the service file to use absolute paths
|
||||
REPO_ROOT="$SCRIPT_DIR"
|
||||
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
||||
@ -159,25 +167,17 @@ systemctl --user daemon-reload
|
||||
# Enable the service to start on login (one-shot, no periodic timer)
|
||||
systemctl --user enable "$SERVICE_NAME"
|
||||
|
||||
# Enable the early bird re-check timer
|
||||
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME"
|
||||
# Timers: early bird re-check, periodic re-check, workout sync, the instant
|
||||
# bonus pass on ledger writes, and the daily RunnerUp retirement retry
|
||||
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME" "$LOCKER_TIMER_NAME" \
|
||||
"$SYNC_TIMER_NAME" "$BONUS_PATH_NAME" "$RETIRE_TIMER_NAME"
|
||||
|
||||
# Enable the periodic re-check timer
|
||||
systemctl --user enable --now "$LOCKER_TIMER_NAME"
|
||||
|
||||
# Enable the periodic workout sync
|
||||
systemctl --user enable --now "$SYNC_TIMER_NAME"
|
||||
|
||||
# Enable the instant bonus pass on ledger writes
|
||||
systemctl --user enable --now "$BONUS_PATH_NAME"
|
||||
|
||||
# Enable the instant RunnerUp credit on upload (restart picks up a new script)
|
||||
systemctl --user enable "$RUNNERUP_WATCH_NAME"
|
||||
systemctl --user restart "$RUNNERUP_WATCH_NAME"
|
||||
|
||||
# Enable the LAN workout poke listener (restart picks up new code)
|
||||
systemctl --user enable "$WORKOUT_POKE_NAME"
|
||||
systemctl --user restart "$WORKOUT_POKE_NAME"
|
||||
# Instant RunnerUp credit on upload, and the LAN workout poke listener
|
||||
# (restart picks up new code)
|
||||
for unit in "$RUNNERUP_WATCH_NAME" "$WORKOUT_POKE_NAME"; do
|
||||
systemctl --user enable "$unit"
|
||||
systemctl --user restart "$unit"
|
||||
done
|
||||
|
||||
# Verify enforcement is actually armed. enable can silently no-op when systemd
|
||||
# breaks an ordering cycle by deleting the timer's job -- exactly what happened
|
||||
|
||||
@ -1,211 +0,0 @@
|
||||
> **This file is a ready-to-use prompt.** Open this repo and say
|
||||
> "do dopamine-ux-04-screen-locker". It is self-contained -- it needs no context from
|
||||
> any other session.
|
||||
>
|
||||
> Canonical copy: `~/src/utils/dopamine-ux/04-screen-locker.md` (source of truth; this is a
|
||||
> distributed copy). Running order and cross-prompt rules: `~/src/utils/dopamine-ux/00-INDEX.md`.
|
||||
> Generated 2026-08-16 from a survey of this repo -- line numbers are accurate
|
||||
> as of that date; **anchor on the symbol names, not the line numbers.**
|
||||
|
||||
## ⛔ WHEN YOU FINISH THIS PROMPT
|
||||
|
||||
Do **both**, in the same session that completes the work:
|
||||
|
||||
1. **Delete this file** and commit that deletion together with the
|
||||
implementation:
|
||||
```bash
|
||||
git rm prompts/dopamine-ux-04-screen-locker.md
|
||||
```
|
||||
Do not leave a finished prompt lying in the repo -- a stale prompt is
|
||||
indistinguishable from a pending one, and the next session will re-run it.
|
||||
|
||||
2. **Log completion in the canonical index**, in `~/src/utils` (a *different* repo,
|
||||
so it needs its own commit):
|
||||
```bash
|
||||
cd ~/src/utils
|
||||
# append to the "Completion log" table in dopamine-ux/00-INDEX.md:
|
||||
# | 04-screen-locker.md | DONE <YYYY-MM-DD> | <impl commit sha> | <one-line note> |
|
||||
git add dopamine-ux/00-INDEX.md
|
||||
git commit -m "dopamine-ux: mark 04-screen-locker.md done"
|
||||
git push
|
||||
```
|
||||
|
||||
If you finish only part of the prompt, do **neither** -- instead edit this file
|
||||
to strike out what is done and state what remains, so the next session starts
|
||||
from the true state. Partial completion must never be recorded as DONE.
|
||||
|
||||
---
|
||||
|
||||
Surface the workout streak that screen-locker already tracks
|
||||
|
||||
## findings (3, ranked by value over effort)
|
||||
|
||||
1. **`snapshot.streak` is populated and no renderer reads it** — the cheapest
|
||||
win in the whole project. Zero new state, zero conftest change, zero deps.
|
||||
2. **Week-transition reward strings are discarded into `_logger.info`** —
|
||||
fully-formed celebratory sentences that never reach a user.
|
||||
3. **The success moment exists only on the lock screen**, for 1.5s. The status
|
||||
window — the surface visited *voluntarily* — shows only enforcement.
|
||||
|
||||
## what
|
||||
|
||||
screen-locker computes a real workout streak and celebratory reward text, then
|
||||
throws almost all of it away. Verified 2026-08-16:
|
||||
|
||||
- `StatusSnapshot` (`screen_locker/_status_types.py` ~:88-101) carries populated
|
||||
`streak` and `early_bird_extended` fields, filled by
|
||||
`_status_data.py` ~:210-214. **Grep confirms no renderer reads them.**
|
||||
- `_extra_benefits.py` `process_week_transition()` (~:82, reward strings built at
|
||||
~:130-139) produces fully-formed celebratory sentences — and
|
||||
`_startup_checks.py` ~:57-58 sends them to `_logger.info` and nowhere else.
|
||||
- The one success moment that exists is on the **lock screen**:
|
||||
`_unlock_view.py` `unlock_screen()` (~:25) paints "Great job! 💪" and
|
||||
`🔥 {streak}-week streak`, for `_UNLOCK_DELAY_MS = 1500` before routing away.
|
||||
|
||||
So the surface the user visits **voluntarily** — the status window, opened from
|
||||
the tray icon — shows enforcement and budget depletion, and no evidence of
|
||||
success at all. This is the cheapest high-value change in the whole project:
|
||||
the data already exists, is already true, and needs no new state.
|
||||
|
||||
## where
|
||||
|
||||
Repo: `~/src/screen-locker`.
|
||||
|
||||
Primary:
|
||||
- `screen_locker/_status_sections.py` — the section renderers used by the status
|
||||
window (`_section_*` functions). **177 lines; has headroom.** Add
|
||||
`_section_streak` here.
|
||||
- `screen_locker/status_view.py` — `StatusWindow`, `render()`. Call the new
|
||||
section from here. ⚠️ **246 lines — only 4 left under the 250 cap.** Expect to
|
||||
extract something, or add the call in a way that costs ~1 line.
|
||||
- `screen_locker/_status_types.py` — `StatusSnapshot` (~:88-101), the fields you
|
||||
are rendering. Read-only for this task.
|
||||
- `screen_locker/_extra_benefits.py` (179 lines) — `process_week_transition()`
|
||||
and its reward strings; `current_streak()`, `has_extended_early_bird()`.
|
||||
- `screen_locker/_startup_checks.py` ~:57-58 — where the reward strings are
|
||||
currently discarded into a log call.
|
||||
- `screen_locker/_unlock_view.py` (72 lines, lots of headroom) — the existing
|
||||
success moment, if you extend it.
|
||||
|
||||
## must
|
||||
|
||||
- **Render the already-populated fields.** Add `_section_streak` to
|
||||
`_status_sections.py` showing the weekly streak and the extended-early-bird
|
||||
state, and call it from `status_view.py`'s `render()`.
|
||||
Zero new state, zero conftest change, zero new dependency.
|
||||
- **Stop discarding the reward strings.** `process_week_transition()` returns
|
||||
human-readable celebratory sentences that currently only reach the log. Surface
|
||||
them where the user will actually see them — the status window is the obvious
|
||||
home; a milestone is worth more than a silent counter increment.
|
||||
- Match the existing positive-status styling rather than inventing one:
|
||||
`_status_sections.py` ~:73-78 already renders `"{n} above the weekly minimum!"`
|
||||
in success green, and uses `✓` marks at ~:36 and ~:57.
|
||||
- Keep the streak **true**. It counts real ISO weeks with ≥5 counted workouts
|
||||
(`_weekly_check.py`: `WEEKLY_WORKOUT_MINIMUM = 5`, `COUNTED_WORKOUT_TYPES =
|
||||
{phone_verified, runnerup_verified, manual_workout}`). Do not round, pad, or
|
||||
"encourage" the number.
|
||||
|
||||
- must not: **make any reward reduce enforcement.** This is recorded in the
|
||||
repo as a deliberate decision:
|
||||
> `_startup_checks.py` — "there is no banked 'skip a workout' credit —
|
||||
> that mechanic works against the goal of maximizing weekly workouts, so it was
|
||||
> removed."
|
||||
|
||||
Every reward here is **display only**. Do not reintroduce a
|
||||
banked skip, a streak freeze, a grace day, or any mechanic that lowers the
|
||||
floor. A dopamine loop that lets you skip the workout defeats the app.
|
||||
- must not: **grant shutdown time for a streak, a week or a milestone.** The
|
||||
weekly banked shutdown bonus (and the 4-week `_MILESTONE_INTERVAL` +1h) was
|
||||
removed on purpose (2026-10-10): every shutdown minute comes from the
|
||||
`earned_time` ladder and stops at its daily ceiling. Rewards here are display
|
||||
only — no "+Nh" weekly reward, no bonus-hours field, no new shutdown source.
|
||||
- must not: add **sound** to any enforcement or lock path. screen-locker is a
|
||||
self-restriction tool; a chime on lock is punishment audio, and a chime on
|
||||
unlock trains the wrong association. This repo stays silent. (Sound is
|
||||
opt-out-on elsewhere in the fleet; here it is simply absent.)
|
||||
- must not: add a write tool to the MCP server. `_mcp.py` ~:14-26 states it is
|
||||
read-only by design. Adding `streak` to the existing `get_status` output is
|
||||
fine; a new action tool violates a stated invariant.
|
||||
- must not: add `# noqa` (banned repo-wide) or a silent `except`
|
||||
(`scripts/check_silent_failures.py` — every handler re-raises or logs at
|
||||
warning or above).
|
||||
- must not: create a new on-disk state file unless genuinely unavoidable. If you
|
||||
do, you **must** add it to `_ISOLATED_STATE` in
|
||||
`screen_locker/tests/conftest.py` (~:134), listing *every* module that binds
|
||||
the path at import time — a missed binding lets tests write to kuhy's real
|
||||
state. Read the comment at ~:131-133 and the fixture docstring at ~:170-175.
|
||||
|
||||
- optional: a daily streak alongside the weekly one. Note this **would** be new
|
||||
state (the existing streak is consecutive-*weeks*), so it triggers the conftest
|
||||
requirement above. diet-guard's consecutive-*day* streak
|
||||
(`~/src/diet-guard/diet_guard/_daystatus.py` ~:144) is the precedent to mirror.
|
||||
|
||||
## done
|
||||
|
||||
1. Opening the status window (tray icon → click, or `screen-locker-status`) shows
|
||||
the current streak and early-bird state — with the real values
|
||||
from `extra_benefits_state.json`, not placeholders.
|
||||
2. A week-transition milestone reaches the UI instead of only `_logger.info`.
|
||||
3. `cd ~/src/screen-locker && python -m pytest` passes.
|
||||
4. `pre-commit run --files <changed files>` is clean — including the 250-line cap
|
||||
(`scripts/check_file_length.py`) and the silent-failure check.
|
||||
5. `git diff` touches no enforcement logic: no change to lock decisions, weekly
|
||||
minimums, or shutdown floors.
|
||||
|
||||
## verify
|
||||
|
||||
Desktop — this is a desktop Python app.
|
||||
|
||||
```
|
||||
cd ~/src/screen-locker
|
||||
python -m screen_locker --status # CLI: confirm the values it prints
|
||||
screen-locker-status # the Tk window: confirm they render
|
||||
```
|
||||
|
||||
Paste both outputs. The CLI already prints the streak and early-bird state
|
||||
(`_status.py`), so it is your ground truth: the window must show the **same numbers**.
|
||||
If they disagree, the renderer is wrong, not the data.
|
||||
|
||||
Current real state for comparison: `screen_locker/extra_benefits_state.json` and
|
||||
`screen_locker/log.json` (12 days of entries, 2026-07-12 → 2026-08-15).
|
||||
|
||||
## read first
|
||||
|
||||
- `screen_locker/_status_sections.py` — every existing `_section_*`. Match their
|
||||
signature and style; note the success-green precedent at ~:73-78.
|
||||
- `screen_locker/_status_types.py` ~:88-101 — the three fields you are rendering.
|
||||
- `screen_locker/_extra_benefits.py` — especially the module docstring ~:5-8
|
||||
(the enforcement invariant) and the reward strings ~:130-139.
|
||||
- `screen_locker/_unlock_view.py` — the existing celebration, for tone.
|
||||
- `screen_locker/status_view.py` — check its line count **before** editing.
|
||||
- `~/src/screen-locker/CLAUDE.md` — repo rules (no silent failures, ruff `select=ALL`).
|
||||
|
||||
## context you would otherwise rediscover
|
||||
|
||||
- **`log.json` is the single source of truth**, written only through
|
||||
`_log_mixin.py` `write_signed_entry()` (~:82), HMAC-signed via
|
||||
`gatelock.log_integrity`, deduped on `workout_id`. The Flutter app converges on
|
||||
it via `_manual_push.py`. There is no second history — a streak computed from
|
||||
it is authoritative.
|
||||
- All persistence is **JSON files, no sqlite**; paths declared in
|
||||
`screen_locker/_constants.py`. The streak store is
|
||||
`screen_locker/extra_benefits_state.json` (`consecutive_5plus_weeks`,
|
||||
`last_processed_iso_week`, `extended_early_bird_iso_weeks`; a legacy
|
||||
`weekly_shutdown_bonus_hours` map is ignored and dropped on the next write).
|
||||
- The threshold lives in `_extra_benefits.py`: `_BONUS_THRESHOLD = 5` (workouts
|
||||
per week, for the streak and the early-bird extension). There is no milestone
|
||||
constant any more.
|
||||
- **There is no config system** — no argparse, no env vars, no config file. Every
|
||||
tunable is a module-level constant. An opt-out toggle has no existing home;
|
||||
for a self-restriction tool, requiring a code edit to disable is arguably
|
||||
correct, so prefer a constant in `_constants.py` over new state.
|
||||
- `~/gatelock` is **not** a repo — it is `~/src/utils/gatelock/gatelock/`, consumed as
|
||||
a pinned git dep, and it holds no workout state.
|
||||
- Manual workouts were **not** removed: `_manual_workout.py` implements a
|
||||
rate-limited, evidence-gated subsystem (budget 2 per 7 days, 10 per 30).
|
||||
- Three UI surfaces exist: the Tk status window (`status_view.py`), a GTK tray
|
||||
icon at `~/.config/i3/scripts/screen_locker_tray.py` (outside the repo, shells
|
||||
out to the CLI, refreshes every 60s), and the lock screen itself. No i3blocks
|
||||
integration exists despite `format_summary_line` being described as one.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -1,232 +0,0 @@
|
||||
> **This file is a ready-to-use prompt.** Open this repo and say
|
||||
> "do dopamine-ux-05-workout-app". It is self-contained -- it needs no context from
|
||||
> any other session.
|
||||
>
|
||||
> Canonical copy: `~/src/utils/dopamine-ux/05-workout-app.md` (source of truth; this is a
|
||||
> distributed copy). Running order and cross-prompt rules: `~/src/utils/dopamine-ux/00-INDEX.md`.
|
||||
> Generated 2026-08-16 from a survey of this repo -- line numbers are accurate
|
||||
> as of that date; **anchor on the symbol names, not the line numbers.**
|
||||
|
||||
## ⛔ WHEN YOU FINISH THIS PROMPT
|
||||
|
||||
Do **both**, in the same session that completes the work:
|
||||
|
||||
1. **Delete this file** and commit that deletion together with the
|
||||
implementation:
|
||||
```bash
|
||||
git rm prompts/dopamine-ux-05-workout-app.md
|
||||
```
|
||||
Do not leave a finished prompt lying in the repo -- a stale prompt is
|
||||
indistinguishable from a pending one, and the next session will re-run it.
|
||||
|
||||
2. **Log completion in the canonical index**, in `~/src/utils` (a *different* repo,
|
||||
so it needs its own commit):
|
||||
```bash
|
||||
cd ~/src/utils
|
||||
# append to the "Completion log" table in dopamine-ux/00-INDEX.md:
|
||||
# | 05-workout-app.md | DONE <YYYY-MM-DD> | <impl commit sha> | <one-line note> |
|
||||
git add dopamine-ux/00-INDEX.md
|
||||
git commit -m "dopamine-ux: mark 05-workout-app.md done"
|
||||
git push
|
||||
```
|
||||
|
||||
If you finish only part of the prompt, do **neither** -- instead edit this file
|
||||
to strike out what is done and state what remains, so the next session starts
|
||||
from the true state. Partial completion must never be recorded as DONE.
|
||||
|
||||
---
|
||||
|
||||
Close the feedback asymmetry in workout_app
|
||||
|
||||
## findings (4, ranked by value over effort)
|
||||
|
||||
1. **Finishing a workout gets no cue** — while the rest timer gets sound *and*
|
||||
an 800ms vibration. The app celebrates waiting and ignores achieving. All
|
||||
infrastructure is already present; only the call is missing.
|
||||
2. **Set completion has no haptic** — the app's most repeated action, silent.
|
||||
3. **The fleet's only animation is an inline magic number** —
|
||||
`Duration(milliseconds: 200)` in `exercise_tile_rows.dart`.
|
||||
4. **No sound opt-out exists** — the SQLite settings table is ready, but no
|
||||
public accessor and no `SwitchListTile` anywhere. Step 2 only.
|
||||
|
||||
## what
|
||||
|
||||
workout_app is the only app in kuhy's fleet that can already play a sound and
|
||||
vibrate — and it spends that capability on the wrong moment. Verified 2026-08-16:
|
||||
|
||||
- **Rest-timer expiry** gets a full cue: `_playBreakEndCue()` in
|
||||
`lib/screens/workout_screen_session.dart` (~:128-145) plays
|
||||
`assets/sounds/break_end.mp3` and fires `Vibration.vibrate(duration: 800)`.
|
||||
- **Finishing an entire workout** gets nothing. `_persistFinishedWorkout()` in
|
||||
`lib/screens/workout_screen_finish.dart` (~:99-108) ends by showing
|
||||
`WorkoutSummaryDialog` with no sound, no haptic, no animation.
|
||||
- **Completing a set** — the core repeated action of the whole app — gets no
|
||||
haptic either. The `GestureDetector` at `lib/widgets/exercise_tile_rows.dart`
|
||||
(~:145) toggles state silently.
|
||||
|
||||
So the app celebrates *waiting* and ignores *achieving*. Fix the asymmetry: the
|
||||
moments worth reinforcing are completing a set and finishing a workout.
|
||||
|
||||
This app also contains the fleet's **only animation** — a lone
|
||||
`AnimatedContainer(duration: Duration(milliseconds: 200))` at
|
||||
`exercise_tile_rows.dart` ~:146-147, with an inline magic number. Replace that
|
||||
number with a motion token while you are in the file.
|
||||
|
||||
## where
|
||||
|
||||
Repo: `~/src/screen-locker`. App: `~/src/screen-locker/stronglift_replacement/workout_app`
|
||||
(a Flutter app nested inside the screen-locker repo; that directory contains **no
|
||||
Python** — only the app and two design docs).
|
||||
|
||||
Primary:
|
||||
- `lib/widgets/exercise_tile_rows.dart` — set-completion `GestureDetector` ~:145,
|
||||
the `AnimatedContainer` ~:146-147.
|
||||
- `lib/screens/workout_screen_finish.dart` — `_persistFinishedWorkout()` ~:99-108.
|
||||
- `lib/widgets/workout_summary_dialog.dart` — `WorkoutSummaryDialog` ~:10.
|
||||
- `lib/screens/workout_screen_session.dart` — `_playBreakEndCue()` ~:128-145, the
|
||||
existing cue to model the new ones on. Note its `.catchError` only
|
||||
`debugPrint`s (~:131-137).
|
||||
- `lib/screens/workout_screen.dart` — `_audio = AudioPlayer()` ~:78, `vibration`
|
||||
import ~:9.
|
||||
- `lib/widgets/rep_circle.dart` — `RepCircle` ~:34, per-set completion indicator.
|
||||
|
||||
Progress/history surfaces (already rich; animate rather than rebuild):
|
||||
- `lib/screens/history_screen_charts.dart` — `_ProgressStatsCard` ~:71,
|
||||
**`_StreakRow` ~:139**, `_WeightChart` ~:192.
|
||||
- `lib/screens/history_screen_painter.dart` — `_ChartPainter` ~:9, `paint()` ~:46,
|
||||
`shouldRepaint` ~:121 (static; no animation today).
|
||||
- Streak data: `successStreak`/`failStreak` on `ExerciseState`
|
||||
(`lib/services/storage_service.dart` ~:42-46).
|
||||
|
||||
Settings (for the sound opt-out):
|
||||
- `lib/screens/settings_screen.dart` — `build()` ~:166, `ListView` ~:174-208.
|
||||
Parts: `settings_screen_rows.dart`, `settings_screen_sections.dart`,
|
||||
`settings_screen_exercise_sections.dart`, `settings_screen_thresholds.dart`,
|
||||
`settings_screen_widget.dart`, `settings_screen_actions.dart`.
|
||||
**No `SwitchListTile` exists** — model a new section on `_SyncSection`
|
||||
(`settings_screen_sections.dart` ~:14) or `_OfflineBackupSection`.
|
||||
- Store: **SQLite**, not SharedPreferences. There is a ready-made key/value table
|
||||
— schema `lib/services/storage_service_schema.dart` ~:34-36
|
||||
(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, migration
|
||||
~:64-65), accessors `lib/services/storage_service.dart` ~:122-137
|
||||
(`_getSetting`/`_setSetting`, both **private** — add a public typed wrapper like
|
||||
the existing `setLastWorkoutType`). Already backed up/restored via
|
||||
`lib/services/storage_service_backup.dart` ~:23, :43, :156-163.
|
||||
|
||||
Theme: `lib/ui/theme.dart` — local hand-copy, **no `design_system` dependency**
|
||||
(`buildAppTheme()` ~:12, `AppStatusColors` ~:76, `AppSpacing` ~:111,
|
||||
`AppRadius` ~:133, `AppTextSize`). Wired at `lib/main.dart` ~:115.
|
||||
|
||||
## must
|
||||
|
||||
**Step 1 — motion + haptics (no new dependency).**
|
||||
|
||||
- Add `HapticFeedback.selectionClick()` on **set completion** — the tap at
|
||||
`exercise_tile_rows.dart` ~:145. This is the app's most repeated action; it
|
||||
should feel like flipping a physical switch. Fire on tap, not after the write.
|
||||
- Add a completion cue when a **workout finishes** — haptic plus a visible
|
||||
arrival for `WorkoutSummaryDialog`, rather than it appearing instantly.
|
||||
- Replace the inline `Duration(milliseconds: 200)` at ~:146-147 with the motion
|
||||
token from prompt 01, transcribed into `lib/ui/theme.dart`.
|
||||
- Animate the streak/stat numbers on the history screen to their new values
|
||||
instead of snapping. `_ChartPainter.shouldRepaint` currently returns a static
|
||||
result — leave the chart itself alone unless animating it is cheap.
|
||||
- Honour `MediaQuery.of(context).disableAnimations` — durations collapse to zero;
|
||||
haptics and sound still fire.
|
||||
- `VIBRATE` is already granted (`android/app/src/main/AndroidManifest.xml` ~:10)
|
||||
and `vibration: ^3.1.0` is already a dependency. Prefer `HapticFeedback` from
|
||||
`flutter/services.dart` for short UI taps and reserve the `vibration` package
|
||||
for the long timed buzz it already does well.
|
||||
|
||||
**Step 2 — sound (separate, independently revertable).**
|
||||
|
||||
- Add a finish-workout cue asset alongside `assets/sounds/break_end.mp3`
|
||||
(declared at `pubspec.yaml` ~:52-53 — **this is the fleet's only `assets:`
|
||||
block**, so you are adding a file, not the block).
|
||||
- Sound ships **on**, with an opt-out. Add a public
|
||||
`getSoundEnabled()`/`setSoundEnabled(bool)` on `StorageService` wrapping
|
||||
`_getSetting`/`_setSetting` (store `'true'`/`'false'`), then a settings section
|
||||
with a `SwitchListTile`.
|
||||
- The toggle must silence the **new** cues and the existing break-end cue alike —
|
||||
one switch, not two behaviours.
|
||||
|
||||
**Both steps:**
|
||||
|
||||
- must not: **reward the weight or rep number.** The cue fires on *logging a set*
|
||||
and *finishing a workout*, not on hitting a PB or beating last week. Rewarding
|
||||
the number pushes toward misreporting; rewarding the record keeps the data
|
||||
honest. A deloaded set gets the same confirmation as a PB.
|
||||
- must not: fabricate the streak. `successStreak`/`failStreak` count real
|
||||
outcomes; display them as they are.
|
||||
- must not: add a cue to *deleting* a set or workout.
|
||||
- must not: silently swallow an audio error. The existing `.catchError` →
|
||||
`debugPrint` at ~:131-137 is the local precedent; do not make it quieter.
|
||||
- must not: break the **100% coverage gate** — new public methods on
|
||||
`StorageService` need tests. See the comments at
|
||||
`settings_screen_sections.dart` ~:1-11.
|
||||
- must not: exceed the **250-line cap** on any file.
|
||||
|
||||
- optional: a subtle near-miss cue on the history screen when a streak is one
|
||||
workout short of a milestone — legitimate only because the number is true.
|
||||
|
||||
## done
|
||||
|
||||
1. Completing a set produces an immediate haptic on the phone.
|
||||
2. Finishing a workout produces a haptic and a visible dialog arrival; step 2
|
||||
adds a sound.
|
||||
3. No inline animation duration remains in `exercise_tile_rows.dart`.
|
||||
4. `cd ~/src/screen-locker/stronglift_replacement/workout_app && flutter analyze` clean.
|
||||
5. `flutter test` passes **and coverage stays at 100%**.
|
||||
6. With OS "remove animations" on, the app works and durations are zero.
|
||||
7. Step 2: the settings toggle flips, persists across restart, and silences both
|
||||
the new cues and the existing break-end cue.
|
||||
|
||||
## verify
|
||||
|
||||
**On the phone.**
|
||||
|
||||
```
|
||||
adb devices # confirm 23181JEGR08034
|
||||
cd ~/src/screen-locker/stronglift_replacement/workout_app
|
||||
flutter build apk --release
|
||||
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
Never `flutter install`, never `adb uninstall`, never `pm clear` — those wipe the
|
||||
SQLite workout history. `adb install -r` preserves it.
|
||||
|
||||
Then run a real set on the device: tap a set complete, finish a workout, and
|
||||
report what the phone actually did. Haptics and sound are physical outcomes —
|
||||
state what you felt and heard; do not infer them from the code.
|
||||
|
||||
## read first
|
||||
|
||||
- `lib/screens/workout_screen_session.dart` ~:128-145 — `_playBreakEndCue()`, the
|
||||
working cue. Your new cues should look like it.
|
||||
- `lib/widgets/exercise_tile_rows.dart` — the set-completion tap and the fleet's
|
||||
only `AnimatedContainer`.
|
||||
- `lib/services/storage_service.dart` ~:122-137 — `_getSetting`/`_setSetting`, and
|
||||
`setLastWorkoutType` as the public-wrapper pattern.
|
||||
- `lib/screens/settings_screen_sections.dart` ~:1-11 — the coverage/line-cap
|
||||
comments, and `_SyncSection` as the section pattern.
|
||||
- `~/src/utils/unified-design-system/motion.md` — motion/haptic vocabulary from
|
||||
prompt 01. **Prompt 01 must have run first.**
|
||||
|
||||
## context you would otherwise rediscover
|
||||
|
||||
- This app has **no `design_system` dependency**; `lib/ui/theme.dart` is a
|
||||
hand-transcription of the shared tokens. Motion tokens must be transcribed
|
||||
there, matching the existing convention. Do not add the dependency here.
|
||||
- `shared_preferences` **is** a dependency but is used only for the device id
|
||||
(`lib/services/sync_device_id.dart` ~:37-38). Settings go in SQLite.
|
||||
- Non-motion `Duration`s that are **not** animation and must not be touched:
|
||||
rest tickers (`workout_screen.dart` ~:91, `workout_screen_breaks.dart` ~:20,
|
||||
`workout_screen_session.dart` ~:37 — 1s), settings debounces
|
||||
(`settings_screen.dart` ~:140, :148 — 600ms), `main.dart` ~:56 (20s).
|
||||
- The app syncs to the screen-locker Python side, which treats
|
||||
`screen_locker/log.json` as the single source of truth. **Do not change
|
||||
what counts as a workout** — enforcement depends on it, and it is out of scope.
|
||||
- The repo enforces a 250-line file cap and bans `# noqa`; ruff runs
|
||||
`select = ["ALL"]` on the Python side.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -7,11 +7,12 @@ requires-python = ">=3.14"
|
||||
# crdt-sync: shared CRDT merge scheme + sync transports, also used by
|
||||
# diet_guard; here for phone-workout sync, Firebase RTDB only since 2026-10-09
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.11.0#subdirectory=gatelock",
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
|
||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
|
||||
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time",
|
||||
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays",
|
||||
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time",
|
||||
"holds @ git+https://github.com/kuhyx/utils@holds-v0.1.0#subdirectory=holds",
|
||||
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@ -97,16 +98,6 @@ unfixable = []
|
||||
# -truth reuse status_view.py above is exempted for — and _make_bare_verifier
|
||||
# imports ScreenLocker inside the function to avoid an import cycle.
|
||||
"screen_locker/_status_view_verify.py" = ["PLC0415", "SLF001"]
|
||||
# _workout_handoff.py must pause gatelock's recovery watchdog while the Flutter
|
||||
# app holds the screen. The loop re-takes any grab it finds missing every
|
||||
# 1000ms -- by design, so that pulling a cable is not an escape -- and
|
||||
# holds_grab() is `grab_current() is not None`, which is False once another X
|
||||
# client holds it. Left running it would steal the screen back from the workout
|
||||
# app within a second. gatelock exposes no public pause (only close(), which
|
||||
# also restores VT switching and would hand the user Ctrl+Alt+F2), and adding
|
||||
# one there would contradict _recovery.py's monotonicity proof that no
|
||||
# transition ever decreases the grab.
|
||||
"screen_locker/_workout_handoff.py" = ["SLF001"]
|
||||
# explain_lock_decision takes one arg per real file path and input, and
|
||||
# returns early per lock-decision-chain stage (see module docstring) — both
|
||||
# are the deliberate design, not something to collapse into a config object.
|
||||
@ -180,17 +171,14 @@ exclude = [".venv/"]
|
||||
init-hook = "import sys; sys.path.insert(0, '.')"
|
||||
analyse-fallback-blocks = true
|
||||
persistent = true
|
||||
jobs = 0
|
||||
# Serial on purpose. With jobs != 1 pylint ships the linter to its workers via
|
||||
# dill.dumps(), and dill 0.4.1 (newest release) reads code.co_lnotab, which
|
||||
# CPython 3.15 removed -- every run crashed once setup-python's "3.x" became
|
||||
# 3.15.1. The fix sits on dill master, unreleased; return to 0 once a dill
|
||||
# release past 0.4.1 ships. The pre-commit hooks take this value too.
|
||||
jobs = 1
|
||||
py-version = "3.14"
|
||||
ignore = [".venv", "__pycache__"]
|
||||
# Test suites intentionally use patterns (protected-access, fixture-arg
|
||||
# shadowing) that don't apply to source code; ruff's per-file-ignores carry
|
||||
# the equivalent exemptions, so tests stay linted -- by ruff. This is the same
|
||||
# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has
|
||||
# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the
|
||||
# basename of what pylint is *given*, and pre-commit hands it explicit file
|
||||
# paths, so a directory name in `ignore` never fires under the hook.
|
||||
ignore-paths = ["^(.*/)?tests/.*$"]
|
||||
ignore-patterns = [".*\\.pyi$"]
|
||||
|
||||
[tool.pylint.messages_control]
|
||||
@ -200,7 +188,42 @@ disable = []
|
||||
[tool.pylint.design]
|
||||
min-public-methods = 0
|
||||
max-module-lines = 1000
|
||||
max-attributes = 10
|
||||
# ScreenLocker composes ~30 mixins; collapsing them is not the goal.
|
||||
max-parents = 40
|
||||
# Keyword-only arguments count: the gather_*/explain_* entry points take one
|
||||
# per real file path so tests can override each.
|
||||
max-args = 8
|
||||
# ManualWorkoutDraft is a flat 23-field record mirroring the evidence form and
|
||||
# the wire payload; StatusWindow / ScreenLocker hold UI and injected state.
|
||||
max-attributes = 25
|
||||
|
||||
[tool.pylint.similarities]
|
||||
# The remaining overlaps are 6-11 line fragments of deliberately parallel code
|
||||
# (Tk button rows, the two JSON ledgers' loaders, mixin host stubs); a real
|
||||
# copy-paste block is longer than this.
|
||||
min-similarity-lines = 12
|
||||
|
||||
[tool.pylint.format]
|
||||
# BaseHTTPRequestHandler dispatches on the method names do_GET / do_POST.
|
||||
good-names-rgxs = "^do_(GET|POST)$"
|
||||
|
||||
[tool.pylint.classes]
|
||||
# The mixin-host surface: module-level helpers (shutdown_base, status, the
|
||||
# status window's verify flow) drive the ScreenLocker's own mixin methods, the
|
||||
# same single-source-of-truth reuse ruff exempts via per-file SLF001.
|
||||
exclude-protected = [
|
||||
"_asdict", "_fields", "_replace", "_source", "_make", "os._exit",
|
||||
"_adjust_shutdown_time_by",
|
||||
"_apply_sick_floor",
|
||||
"_apply_workout_credit",
|
||||
"_credit_ingested_workout",
|
||||
"_read_shutdown_config",
|
||||
"_scan_and_fill_week_runnerup",
|
||||
"_try_fill_stronglifts_for_week",
|
||||
"_verify_phone_workout",
|
||||
"_verify_runnerup_workout",
|
||||
"_write_shutdown_config",
|
||||
]
|
||||
|
||||
[tool.pylint.typecheck]
|
||||
generated-members = [
|
||||
@ -231,6 +254,14 @@ addopts = [
|
||||
"--cov-branch",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=lcov",
|
||||
# The commit gate runs plain `python -m pytest -q`; serial it took 26 s of
|
||||
# its 60 s budget, 4 workers 9 s. Fixed, not `auto`: auto is 24 workers
|
||||
# here, past capped.sh's 4 GiB. CI's own `-n auto` comes later and wins;
|
||||
# `-n0` gets a serial run back.
|
||||
"-n",
|
||||
"4",
|
||||
"--dist",
|
||||
"worksteal",
|
||||
# A fixture-only plugin module, not conftest.py (already at the
|
||||
# repo's 400-line-per-file cap) -- see its own docstring.
|
||||
"-p",
|
||||
@ -245,6 +276,9 @@ filterwarnings = [
|
||||
[tool.coverage.run]
|
||||
source = ["screen_locker"]
|
||||
branch = true
|
||||
# sys.monitoring (PEP 669) rather than the C tracer: test_shutdown_ceiling_
|
||||
# invariant.py alone measured 13 s vs 22 s, for the same line+branch data.
|
||||
core = "sysmon"
|
||||
omit = [
|
||||
"*/__pycache__/*",
|
||||
"*/tests/*",
|
||||
|
||||
@ -17,6 +17,8 @@ UNITS=(
|
||||
"earner-bonus.service"
|
||||
"runnerup-watch.service"
|
||||
"workout-poke.service"
|
||||
"runnerup-retire.timer"
|
||||
"runnerup-retire.service"
|
||||
)
|
||||
|
||||
for unit in "${UNITS[@]}"; do
|
||||
|
||||
@ -2,27 +2,28 @@
|
||||
# Runtime: tkinter/subprocess/socket/sqlite3 (stdlib) plus gatelock (below).
|
||||
bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.17.0
|
||||
coverage==7.17.2
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time
|
||||
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time
|
||||
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.11.0#subdirectory=gatelock
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
|
||||
holds @ git+https://github.com/kuhyx/utils@holds-v0.1.0#subdirectory=holds
|
||||
mcp==2.3.0
|
||||
mypy==2.4.0
|
||||
mypy==2.4.1
|
||||
pre-commit==4.7.0
|
||||
pylint==4.1.3
|
||||
pytest==9.1.1
|
||||
pytest==9.2.0
|
||||
pytest-cov==7.1.0
|
||||
pytest-randomly==5.0.0
|
||||
pytest-randomly==5.1.0
|
||||
pytest-sugar==1.1.1
|
||||
pytest-xdist==3.8.0
|
||||
# Direct dependency since the Firebase session recovery exchanges a refresh
|
||||
# token over the REST endpoint; crdt-sync already pulls requests in, but this
|
||||
# module imports it itself, so it is declared rather than assumed.
|
||||
requests==2.34.2
|
||||
ruff==0.17.2
|
||||
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
|
||||
types-requests==2.33.0.20261006
|
||||
ruff==0.17.4
|
||||
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays
|
||||
types-requests==2.33.0.20261030
|
||||
# Direct dependency since the Firebase session stream (_session_stream) catches
|
||||
# urllib3's own errors on the long-lived SSE read; requests pulls it in, but
|
||||
# the module imports it itself, so it is declared rather than assumed.
|
||||
|
||||
15
runnerup-retire.service
Normal file
15
runnerup-retire.service
Normal file
@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Back up and uninstall the stock RunnerUp app a week after the workout app's first credited run
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# screen_locker is pip-installed (editable), like workout-sync.service, so no
|
||||
# WorkingDirectory/PYTHONPATH. Headless: no DISPLAY, no Tk, no lock screen.
|
||||
#
|
||||
# Exit 0 for "not due yet", "no app run credited yet" and "phone not attached"
|
||||
# (the timer retries), non-zero when a human is needed: invalid import marker,
|
||||
# adb missing, a failed backup or a refused uninstall. That shows up in
|
||||
# `systemctl --user --failed`. Every skip says why in the journal:
|
||||
# journalctl --user -u runnerup-retire
|
||||
# State and the anchor: python3 -m screen_locker.runnerup_retire --status
|
||||
ExecStart=/usr/bin/python3 -m screen_locker.runnerup_retire
|
||||
18
runnerup-retire.timer
Normal file
18
runnerup-retire.timer
Normal file
@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Retry the RunnerUp retirement daily until the phone has been cleaned up
|
||||
|
||||
# No After=graphical-session.target: timers.target is ordered before it, and
|
||||
# that After= closes a cycle systemd resolves by deleting this timer's job
|
||||
# (see early-bird-workout-check.timer for the 2026-08-04 incident).
|
||||
|
||||
[Timer]
|
||||
# The phone is not always on USB, so a failed or skipped pass is simply tried
|
||||
# again: shortly after boot and once a day. Most passes are an instant no-op
|
||||
# (not due yet, or already done), so the timer stays installed.
|
||||
OnBootSec=10min
|
||||
OnCalendar=*-*-* 19:00:00
|
||||
# Persistent=true: a PC that was off at 19:00 still gets that day's pass.
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
133
screen_locker/_activity_day.py
Normal file
133
screen_locker/_activity_day.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""A day's activities summed under the one crediting rule (see ``_walk_tcx``).
|
||||
|
||||
The day counts as the workout once EITHER its summed distance reaches
|
||||
``MIN_RUN_DISTANCE_KM`` (within the GPS tolerance) OR its summed moving time
|
||||
reaches the shared duration bar. The sport only labels messages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from screen_locker._constants import (
|
||||
MIN_RUN_DISTANCE_KM,
|
||||
MIN_WORKOUT_DURATION_MINUTES,
|
||||
RUNNERUP_DISTANCE_TOLERANCE,
|
||||
WORKOUT_DURATION_ACCEPT_MINUTES,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
# RunnerUp's sport codes, for labels only (the label never decides credit).
|
||||
SPORT_NAMES: dict[int, str] = {
|
||||
0: "Running",
|
||||
1: "Biking",
|
||||
2: "Other",
|
||||
3: "Orienteering",
|
||||
4: "Walking",
|
||||
5: "Treadmill",
|
||||
6: "Gym",
|
||||
7: "Stationary Bike",
|
||||
}
|
||||
|
||||
|
||||
def distance_qualifies(distance_km: float) -> bool:
|
||||
"""Whether a distance meets the minimum, with the GPS tolerance."""
|
||||
return distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) >= MIN_RUN_DISTANCE_KM
|
||||
|
||||
|
||||
def moving_qualifies(moving_minutes: float) -> bool:
|
||||
"""Whether moving time meets the shared duration bar (leeway included)."""
|
||||
return moving_minutes >= WORKOUT_DURATION_ACCEPT_MINUTES
|
||||
|
||||
|
||||
def _distance_metres(activity: dict[str, Any]) -> float:
|
||||
"""Credited metres; the root-DB path has no trackpoints, so its total stands in."""
|
||||
return float(activity.get("track_distance_m", activity.get("distance_m", 0.0)))
|
||||
|
||||
|
||||
def _moving_seconds(activity: dict[str, Any]) -> float:
|
||||
"""Moving seconds; the root-DB path has no trackpoints, so wall-clock stands in."""
|
||||
return float(activity.get("moving_seconds", activity.get("duration_seconds", 0.0)))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActivityDay:
|
||||
"""A day's activities, summed.
|
||||
|
||||
Attributes:
|
||||
legs: Distinct activities (deduplicated by the activity's ``Id``: the
|
||||
WebDAV copy and the phone's copy are the same activity).
|
||||
moving_minutes: Their summed moving time.
|
||||
distance_km: Their summed distance.
|
||||
completed_at: Unix time the activity that took the day over the bar
|
||||
ended -- when the workout was actually done -- or ``None`` if the
|
||||
day never got there (or the activity recorded no end).
|
||||
all_walks: Whether every activity was a walk (label only).
|
||||
sport: The sport code when the day is a single non-walk activity.
|
||||
"""
|
||||
|
||||
legs: int
|
||||
moving_minutes: float
|
||||
distance_km: float
|
||||
completed_at: float | None
|
||||
all_walks: bool = False
|
||||
sport: int = -1
|
||||
|
||||
@property
|
||||
def qualifies(self) -> bool:
|
||||
"""Whether the day counts as its workout: distance OR moving time."""
|
||||
return distance_qualifies(self.distance_km) or moving_qualifies(
|
||||
self.moving_minutes
|
||||
)
|
||||
|
||||
def message(self) -> str:
|
||||
"""The verification line, in the RunnerUp verifier's own style."""
|
||||
if self.all_walks:
|
||||
return (
|
||||
f"Walking: {self.legs} walk(s), {self.distance_km:.1f} km, "
|
||||
f"{self.moving_minutes:.0f} min moving"
|
||||
)
|
||||
if self.legs == 1:
|
||||
name = SPORT_NAMES.get(self.sport, str(self.sport))
|
||||
return f"{name}: {self.distance_km:.1f} km in {self.moving_minutes:.0f} min"
|
||||
return (
|
||||
f"Activity: {self.legs} activities, {self.distance_km:.1f} km, "
|
||||
f"{self.moving_minutes:.0f} min moving"
|
||||
)
|
||||
|
||||
|
||||
def activity_shortfall(day: ActivityDay) -> str:
|
||||
"""Why a day's activity did not count yet."""
|
||||
return (
|
||||
f"{day.message()} -- need {MIN_RUN_DISTANCE_KM:g}+ km or "
|
||||
f"{MIN_WORKOUT_DURATION_MINUTES}+ min moving"
|
||||
)
|
||||
|
||||
|
||||
def activity_day(activities: Iterable[dict[str, Any]]) -> ActivityDay:
|
||||
"""Sum parsed exports (``parse_tcx_file`` dicts) into one :class:`ActivityDay`."""
|
||||
unique: dict[str, dict[str, Any]] = {}
|
||||
for activity in activities:
|
||||
unique.setdefault(str(activity.get("activity_id") or id(activity)), activity)
|
||||
ordered = sorted(unique.values(), key=lambda a: a.get("ended_at") or 0.0)
|
||||
moving = 0.0
|
||||
metres = 0.0
|
||||
completed_at: float | None = None
|
||||
for activity in ordered:
|
||||
moving += _moving_seconds(activity)
|
||||
metres += _distance_metres(activity)
|
||||
if completed_at is None and (
|
||||
moving_qualifies(moving / 60) or distance_qualifies(metres / 1000)
|
||||
):
|
||||
completed_at = activity.get("ended_at")
|
||||
return ActivityDay(
|
||||
legs=len(ordered),
|
||||
moving_minutes=moving / 60,
|
||||
distance_km=metres / 1000,
|
||||
completed_at=completed_at,
|
||||
all_walks=bool(ordered) and all(a.get("walking") for a in ordered),
|
||||
sport=int(ordered[0].get("sport", -1)) if ordered else -1,
|
||||
)
|
||||
@ -11,7 +11,7 @@ Deciding whether what came back counts as a workout stays in
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ( # pylint: disable=no-name-in-module
|
||||
from concurrent.futures import (
|
||||
ThreadPoolExecutor,
|
||||
as_completed,
|
||||
)
|
||||
|
||||
@ -174,7 +174,7 @@ def locker_unit_active() -> bool | None:
|
||||
if not systemctl_available():
|
||||
return None
|
||||
code, out = _run(["systemctl", "--user", "is-active", LOCKER_UNIT])
|
||||
if code != 0 and not out.strip():
|
||||
if code and not out.strip():
|
||||
return None
|
||||
return out.strip() == "active"
|
||||
|
||||
@ -186,7 +186,7 @@ def _login_target_active() -> bool:
|
||||
True when ``graphical-session.target`` is active.
|
||||
"""
|
||||
code, out = _run(["systemctl", "--user", "is-active", _LOGIN_TARGET])
|
||||
return code == 0 and out.strip() == "active"
|
||||
return not code and out.strip() == "active"
|
||||
|
||||
|
||||
def collect_states() -> list[TimerState]:
|
||||
@ -205,7 +205,7 @@ def collect_states() -> list[TimerState]:
|
||||
states.append(
|
||||
TimerState(
|
||||
name=name,
|
||||
enabled=code == 0 and raw == "enabled",
|
||||
enabled=not code and raw == "enabled",
|
||||
enabled_raw=raw,
|
||||
# For a timer this is "systemd has a next trigger for it";
|
||||
# for the login-run service it is "the target it is wanted by
|
||||
|
||||
@ -24,7 +24,8 @@ import sys
|
||||
|
||||
from screen_locker._decision_log import LockDecision, record_decision
|
||||
from screen_locker._decision_reasons import reasons_extra
|
||||
from screen_locker._morning_session import has_workout_skip_today
|
||||
from screen_locker._hold import workout_hold_active
|
||||
from screen_locker._morning_session import MorningSkip, has_workout_skip_today
|
||||
from screen_locker._night_lockdown import has_evening_lockdown_today
|
||||
from screen_locker._sick import stands_down_workout
|
||||
from screen_locker._weekly_check import has_weekly_minimum, is_relaxed_day
|
||||
@ -73,6 +74,7 @@ class _ReasonsMixin:
|
||||
"relaxed_day": is_relaxed_day,
|
||||
"weekly_minimum_met": lambda: has_weekly_minimum(self.log_file),
|
||||
"evening_lockdown_today": has_evening_lockdown_today,
|
||||
"test_hold_active": workout_hold_active,
|
||||
},
|
||||
)
|
||||
|
||||
@ -151,6 +153,34 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _stand_down_for_carrot(self, skip: MorningSkip) -> None:
|
||||
"""Take an armed lock down because the carrot landed after it armed.
|
||||
|
||||
Does what the ``wake_alarm_skip`` rung above does -- bank the pending
|
||||
marker so the post-carrot run still tries the phone workout, record the
|
||||
decision -- then leaves through the locker's normal ``close`` (the same
|
||||
one a credited workout and a test hold use): lock window released,
|
||||
instance lock dropped, exit 0. Nothing is credited or penalised.
|
||||
"""
|
||||
self._save_early_bird_pending()
|
||||
record_decision(
|
||||
LockDecision(
|
||||
locked=False,
|
||||
reason="wake_alarm_skip_late",
|
||||
detail=f"Morning session carrot landed after the lock armed: {skip}.",
|
||||
extra={
|
||||
"exempt_until": skip.exempt_until.isoformat(),
|
||||
"outcome": skip.outcome,
|
||||
},
|
||||
)
|
||||
)
|
||||
_logger.warning(
|
||||
"Morning-session carrot appeared while the lock was up (%s) — "
|
||||
"taking the lock down; the early-bird marker is banked.",
|
||||
skip,
|
||||
)
|
||||
self.close()
|
||||
|
||||
def _try_auto_upgrade_early_bird(self) -> bool:
|
||||
"""Try phone then RunnerUp to upgrade an early_bird log entry."""
|
||||
try:
|
||||
|
||||
76
screen_locker/_carrot_watch.py
Normal file
76
screen_locker/_carrot_watch.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""An armed lock stands down when wake-alarm's signed carrot lands late.
|
||||
|
||||
2026-11-07: the lock armed at 07:49:17 because today's signed
|
||||
``morning_session.json`` had not landed yet (the refresher lost a network
|
||||
race). It landed at 07:50:00 (``in_progress``, exempt until 09:13) and nothing
|
||||
ever re-read it, so the morning session ran under a fullscreen lock. The
|
||||
startup ladder reads the file once; this is the same verdict, re-asked every
|
||||
``POLL_MS`` while the window is up.
|
||||
|
||||
The decision stays wake-alarm's (signature ok, dated today, now < exempt_until);
|
||||
this module only decides *when to ask* -- 05:00-11:00 local, the hours the
|
||||
refresher runs -- and never waits, since it sits on the Tk loop. The file is
|
||||
read through the ``_morning_session`` module attribute so the test suite's
|
||||
redirected path applies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _morning_session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from tkinter import Misc
|
||||
|
||||
__all__ = ["POLL_MS", "watch_for_carrot"]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
POLL_MS = 15_000
|
||||
# Local minutes after midnight; mirrors gatelock.morning_session.MORNING_WINDOW.
|
||||
_WINDOW_START_MIN = 5 * 60
|
||||
_WINDOW_END_MIN = 11 * 60
|
||||
|
||||
|
||||
def _local_now() -> datetime:
|
||||
return datetime.now(tz=UTC).astimezone()
|
||||
|
||||
|
||||
def _in_window(now: datetime) -> bool:
|
||||
return _WINDOW_START_MIN <= now.hour * 60 + now.minute < _WINDOW_END_MIN
|
||||
|
||||
|
||||
def watch_for_carrot(
|
||||
root: Misc, on_carrot: Callable[[_morning_session.MorningSkip], None]
|
||||
) -> None:
|
||||
"""Hand a late-landing signed carrot to ``on_carrot`` within one poll.
|
||||
|
||||
``on_carrot`` is the locker's own stand-down (bank the early-bird marker,
|
||||
record the decision, then the normal ``close``). Polls on the Tk loop (no
|
||||
thread) and stops re-arming once it fires. A failed read is logged and the
|
||||
poll goes on: one bad read must not end the only thing watching.
|
||||
"""
|
||||
|
||||
def _tick() -> None:
|
||||
now = _local_now()
|
||||
if _in_window(now):
|
||||
try:
|
||||
skip = _morning_session.morning_skip_today(now, wait=False)
|
||||
except (OSError, ValueError, TypeError, KeyError) as exc:
|
||||
_logger.warning(
|
||||
"Could not read the morning session while the lock is up "
|
||||
"(%s) — keeping the lock and trying again in %d s",
|
||||
exc,
|
||||
POLL_MS // 1000,
|
||||
)
|
||||
skip = None
|
||||
if skip is not None:
|
||||
on_carrot(skip)
|
||||
return
|
||||
root.after(POLL_MS, _tick)
|
||||
|
||||
root.after(POLL_MS, _tick)
|
||||
39
screen_locker/_ceiling.py
Normal file
39
screen_locker/_ceiling.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""The latest shutdown a day can earn: ``earned_time``'s ceiling, bounded at 23:00."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# adjust_shutdown_schedule.sh's RESTORE_CEILING (23:00): the hard upper bound
|
||||
# every --restore write is clamped to, and the answer when the lookup fails.
|
||||
HARD_CEILING_MINUTES = 23 * 60
|
||||
|
||||
|
||||
def ceiling(day: date) -> int:
|
||||
"""The latest shutdown ``day`` can earn, minutes after its midnight.
|
||||
|
||||
Never above :data:`HARD_CEILING_MINUTES`. A lookup that raises fails open
|
||||
to that bound -- the same cap the helper enforces anyway -- and says so at
|
||||
warning, so a broken registry cannot silently lift or lower the cap.
|
||||
"""
|
||||
fn = getattr(earned_time, "shutdown_ceiling_for", None)
|
||||
if fn is None:
|
||||
return min(HARD_CEILING_MINUTES, int(earned_time.SHUTDOWN_CEILING_MINUTES))
|
||||
try:
|
||||
return min(HARD_CEILING_MINUTES, int(fn(day)))
|
||||
except (ArithmeticError, LookupError, TypeError, ValueError) as err:
|
||||
_logger.warning(
|
||||
"shutdown ceiling for %s unavailable (%s); using %s",
|
||||
day,
|
||||
err,
|
||||
f"{HARD_CEILING_MINUTES // 60:02d}:{HARD_CEILING_MINUTES % 60:02d}",
|
||||
)
|
||||
return HARD_CEILING_MINUTES
|
||||
@ -6,7 +6,6 @@ Single source of truth for the leaf checks walked by
|
||||
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
|
||||
@ -18,6 +17,7 @@ surfaces the pending opportunity separately via ``AutoUpgradeOpportunity``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@ -38,6 +38,7 @@ from screen_locker._compliance_trace import (
|
||||
describe_degraded_sources,
|
||||
)
|
||||
from screen_locker._day import today_str as local_day
|
||||
from screen_locker._hold import workout_hold_active
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
@ -59,6 +60,142 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Step:
|
||||
"""One predicate of the lock-decision chain, in evaluation order."""
|
||||
|
||||
name: str
|
||||
reason_true: str
|
||||
reason_false: str
|
||||
terminal_reason: str | None = None
|
||||
note_when_false: bool = False
|
||||
|
||||
|
||||
# Order mirrors ``_check_non_verify_exits`` / ``_check_today_state_exits``.
|
||||
# ``early_bird_pending_expired`` is recorded but never terminal on its own: an
|
||||
# expired pending marker still falls through toward a full lock, with the
|
||||
# upgrade opportunity surfaced separately (see module docstring).
|
||||
_CHAIN: tuple[_Step, ...] = (
|
||||
_Step(
|
||||
"scheduled_skip",
|
||||
"Today is in scheduled_skips.json (unconditional manual skip).",
|
||||
"Today is not a manually scheduled skip day.",
|
||||
"Manually scheduled skip day — no lock, no workout required.",
|
||||
),
|
||||
_Step(
|
||||
"early_bird_pending_expired",
|
||||
"Logged in during the early-bird window and it has since closed "
|
||||
"with no workout confirmed yet.",
|
||||
"No expired early-bird pending marker.",
|
||||
),
|
||||
_Step(
|
||||
"early_bird_window_open",
|
||||
"Logged in during the early-bird window earlier today; still "
|
||||
"waiting to see a workout before the window closes.",
|
||||
"Not pending inside an open early-bird window.",
|
||||
"Early-bird window still open — lock skipped while waiting for a workout.",
|
||||
),
|
||||
_Step(
|
||||
"sick_day",
|
||||
"Today is a sick day in the shared sickdays pool.",
|
||||
"Today is not a sick day.",
|
||||
"Sick day today — lock skipped.",
|
||||
),
|
||||
_Step(
|
||||
"already_logged",
|
||||
"A validly-signed workout is already logged for today.",
|
||||
"No workout logged for today yet.",
|
||||
"Workout already logged today — lock skipped.",
|
||||
note_when_false=True,
|
||||
),
|
||||
_Step(
|
||||
"wake_alarm_skip",
|
||||
"The morning session earned a workout skip for now.",
|
||||
"No morning-session skip in force.",
|
||||
"Morning session earned a skip — lock skipped.",
|
||||
),
|
||||
_Step(
|
||||
"test_hold_active",
|
||||
"A declared test hold is active.",
|
||||
"No test hold active.",
|
||||
"Test hold active — no workout window until it ends.",
|
||||
),
|
||||
_Step(
|
||||
"relaxed_day_already_skipped",
|
||||
"Relaxed day already dismissed via Skip — No Penalty today.",
|
||||
"No same-day relaxed-day dismissal on record.",
|
||||
"Relaxed day already skipped today — lock skipped, prompt not shown again.",
|
||||
),
|
||||
_Step(
|
||||
"relaxed_day",
|
||||
"Today is a relaxed day (Tue/Wed/Thu).",
|
||||
"Today is not a relaxed day.",
|
||||
"Relaxed day (Tue/Wed/Thu) — workout optional, lock skipped.",
|
||||
),
|
||||
_Step(
|
||||
"weekly_minimum_met",
|
||||
"Weekly workout minimum already reached.",
|
||||
"Weekly workout minimum not yet reached.",
|
||||
"Weekly workout minimum already met — lock skipped.",
|
||||
),
|
||||
_Step(
|
||||
"evening_lockdown_today",
|
||||
"Tonight's night lockdown already fired (>= 12:00 today).",
|
||||
"No evening night lockdown yet today.",
|
||||
"Night lockdown already happened tonight — the workout lock does not re-arm.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _chain_flags(
|
||||
*,
|
||||
log_file: Path,
|
||||
weekly_minimum_met: bool,
|
||||
relaxed_day: bool,
|
||||
wake_skip: bool,
|
||||
evening_lockdown: bool,
|
||||
local_dt: datetime,
|
||||
pending: bool,
|
||||
) -> dict[str, bool]:
|
||||
"""Evaluate every predicate once; the keys are the ``_CHAIN`` step names."""
|
||||
today_str = local_day(local_dt)
|
||||
# The early-bird window IS the wake-alarm carrot (EarlyBirdMixin).
|
||||
return {
|
||||
"scheduled_skip": is_scheduled_skip_today(local_dt.date()),
|
||||
"early_bird_pending_expired": pending and not wake_skip,
|
||||
"early_bird_window_open": pending and wake_skip,
|
||||
"sick_day": is_sick_day_today(local_dt.date()),
|
||||
"already_logged": has_logged_today(log_file, today=today_str),
|
||||
"wake_alarm_skip": wake_skip,
|
||||
"test_hold_active": workout_hold_active(local_dt),
|
||||
"relaxed_day_already_skipped": relaxed_day
|
||||
and is_relaxed_day_skipped_today(log_file, today=today_str),
|
||||
"relaxed_day": relaxed_day,
|
||||
"weekly_minimum_met": weekly_minimum_met,
|
||||
"evening_lockdown_today": evening_lockdown,
|
||||
}
|
||||
|
||||
|
||||
def _walk_chain(
|
||||
fired: dict[str, bool], context: StageContext, degraded_note: str
|
||||
) -> LockExplanation | None:
|
||||
"""Record each step in ``context.trace``; return the first terminal one."""
|
||||
for step in _CHAIN:
|
||||
hit = fired[step.name]
|
||||
reason = step.reason_true if hit else step.reason_false
|
||||
if step.note_when_false and not hit:
|
||||
reason += degraded_note
|
||||
context.trace.append(PredicateResult(step.name, hit, reason))
|
||||
if hit and step.terminal_reason is not None:
|
||||
return _stage(
|
||||
fired=False,
|
||||
stage=step.name,
|
||||
reason=step.terminal_reason,
|
||||
context=context,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def explain_lock_decision(
|
||||
*,
|
||||
log_file: Path,
|
||||
@ -77,155 +214,25 @@ def explain_lock_decision(
|
||||
reported via ``heat_skip_evaluated=False``, not guessed.
|
||||
"""
|
||||
instant = now if now is not None else datetime.now(tz=UTC)
|
||||
today_str = local_day(instant)
|
||||
local_dt = instant.astimezone()
|
||||
|
||||
scheduled = is_scheduled_skip_today(local_dt.date())
|
||||
pending = is_early_bird_pending(early_bird_pending_file, today=today_str)
|
||||
# The early-bird window IS the wake-alarm carrot (EarlyBirdMixin).
|
||||
window_open = wake_skip
|
||||
expired = pending and not window_open
|
||||
early_bird_open_and_pending = pending and window_open
|
||||
sick_today = is_sick_day_today(local_dt.date())
|
||||
logged = has_logged_today(log_file, today=today_str)
|
||||
relaxed_day_already_skipped = relaxed_day and is_relaxed_day_skipped_today(
|
||||
log_file, today=today_str
|
||||
pending = is_early_bird_pending(early_bird_pending_file, today=local_day(instant))
|
||||
fired = _chain_flags(
|
||||
log_file=log_file,
|
||||
weekly_minimum_met=weekly_minimum_met,
|
||||
relaxed_day=relaxed_day,
|
||||
wake_skip=wake_skip,
|
||||
evening_lockdown=evening_lockdown,
|
||||
local_dt=instant.astimezone(),
|
||||
pending=pending,
|
||||
)
|
||||
|
||||
auto_upgrade = describe_auto_upgrade_opportunity(
|
||||
early_bird_pending=pending,
|
||||
early_bird_window_open=window_open,
|
||||
early_bird_window_open=wake_skip,
|
||||
)
|
||||
|
||||
trace: list[PredicateResult] = []
|
||||
stage_context = StageContext(trace, auto_upgrade, bool(degraded_sources))
|
||||
|
||||
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,
|
||||
context=stage_context,
|
||||
)
|
||||
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 a sick day in the shared sickdays pool.",
|
||||
reason_false="Today is not a sick day.",
|
||||
terminal_reason="Sick day today — lock skipped.",
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
context = StageContext([], auto_upgrade, bool(degraded_sources))
|
||||
degraded_note = describe_degraded_sources(degraded_sources)
|
||||
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." + degraded_note,
|
||||
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="The morning session earned a workout skip for now.",
|
||||
reason_false="No morning-session skip in force.",
|
||||
terminal_reason="Morning session earned a skip — lock skipped.",
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
result = _check(
|
||||
"relaxed_day_already_skipped",
|
||||
fired=relaxed_day_already_skipped,
|
||||
reason_true="Relaxed day already dismissed via Skip — No Penalty today.",
|
||||
reason_false="No same-day relaxed-day dismissal on record.",
|
||||
terminal_reason="Relaxed day already skipped today — lock skipped, "
|
||||
"prompt not shown again.",
|
||||
)
|
||||
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
|
||||
|
||||
result = _check(
|
||||
"evening_lockdown_today",
|
||||
fired=evening_lockdown,
|
||||
reason_true="Tonight's night lockdown already fired (>= 12:00 today).",
|
||||
reason_false="No evening night lockdown yet today.",
|
||||
terminal_reason="Night lockdown already happened tonight — the workout "
|
||||
"lock does not re-arm.",
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
terminal = _walk_chain(fired, context, degraded_note)
|
||||
if terminal is not None:
|
||||
return terminal
|
||||
|
||||
# Named for the conclusion; "..._pending_heat_check" read as "stuck".
|
||||
return _stage(
|
||||
@ -237,5 +244,5 @@ def explain_lock_decision(
|
||||
"temperature is checked, and a run may or may not be happening "
|
||||
"right now (see Health)." + degraded_note
|
||||
),
|
||||
context=stage_context,
|
||||
context=context,
|
||||
)
|
||||
|
||||
@ -53,13 +53,13 @@ WORKOUT_APP_JSON_REMOTES = (
|
||||
)
|
||||
# Port the workout app's HTTP server listens on (no ADB/developer-options needed).
|
||||
WORKOUT_HTTP_PORT = 8765
|
||||
# The ONE duration bar every workout type answers to — weightlifting, manual
|
||||
# entries and runs alike. This is the ADVERTISED number: it is the only one
|
||||
# that may ever appear in a message the user reads.
|
||||
MIN_WORKOUT_DURATION_MINUTES = 40
|
||||
# The ONE duration bar every activity answers to — weightlifting, manual
|
||||
# entries, runs and walks (summed moving time) alike. This is the ADVERTISED
|
||||
# number: it is the only one that may ever appear in a message the user reads.
|
||||
MIN_WORKOUT_DURATION_MINUTES = 30
|
||||
# ...and this is the leeway that is deliberately INVISIBLE. A session is
|
||||
# actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 35 minutes
|
||||
# passes while every string still says 40. That gap is intentional (the user
|
||||
# actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 25 minutes
|
||||
# passes while every string still says 30. That gap is intentional (the user
|
||||
# asked for it explicitly): do NOT "fix" the inconsistency by advertising the
|
||||
# real cutoff, and never interpolate WORKOUT_DURATION_ACCEPT_MINUTES into an
|
||||
# f-string.
|
||||
@ -67,17 +67,17 @@ WORKOUT_DURATION_LEEWAY_MINUTES = 5
|
||||
# Derived so the advertised bar and the accept bar cannot drift apart.
|
||||
# Comparisons use this; messages use MIN_WORKOUT_DURATION_MINUTES. Every
|
||||
# caller compares with `>=` — one operator across all workout types, so a
|
||||
# 35.0-minute session cannot pass one gate and fail another.
|
||||
# 25.0-minute session cannot pass one gate and fail another.
|
||||
WORKOUT_DURATION_ACCEPT_MINUTES = (
|
||||
MIN_WORKOUT_DURATION_MINUTES - WORKOUT_DURATION_LEEWAY_MINUTES
|
||||
)
|
||||
RUNNERUP_PACKAGES = ("org.runnerup", "org.runnerup.free")
|
||||
RUNNERUP_DB_SDCARD_TMP = "/sdcard/.runnerup_tmp_verification.db"
|
||||
# A run qualifies on EITHER criterion — they are an OR, not an AND. A short
|
||||
# but long-lasting run counts, and so does a fast 5 km that takes under the
|
||||
# duration bar. Do not reinstate an AND here: it would reject exactly the
|
||||
# slow-run case this pair exists to allow. The duration arm is the shared
|
||||
# MIN_WORKOUT_DURATION_MINUTES above; only the distance arm is run-specific.
|
||||
# A day's activity (run or walk alike) qualifies on EITHER criterion — they are
|
||||
# an OR, not an AND. A short but long-lasting session counts, and so does a
|
||||
# fast 5 km that takes under the duration bar. Do not reinstate an AND here:
|
||||
# it would reject exactly the slow case this pair exists to allow. The
|
||||
# duration arm is the shared MIN_WORKOUT_DURATION_MINUTES above (moving time).
|
||||
MIN_RUN_DISTANCE_KM = 3.5 # inclusive: "as low as 3.5 km"
|
||||
# GPS distance tracking undershoots on tight turns/tree cover/urban canyons;
|
||||
# a run within 5% of the minimum is accepted rather than rejected on
|
||||
@ -105,11 +105,22 @@ SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json"
|
||||
REST_DAY_FILE = Path(__file__).resolve().parent / "rest_days.json"
|
||||
REST_DAYS_PER_ISO_WEEK = 2
|
||||
GRACE_STATE_FILE = Path(__file__).resolve().parent / "grace_floor.json"
|
||||
# A day's RunnerUp walks (commute legs) count as the workout once their
|
||||
# MOVING time sums to this; moving = trackpoint pairs at walking speed.
|
||||
WALK_MIN_MOVING_MINUTES = 40
|
||||
WALK_SPEED_MPS = (0.5, 3.0)
|
||||
# Every GPS activity (run or walk, whatever its TCX sport) is judged the same
|
||||
# way: a day's activities count as the workout once their summed distance
|
||||
# reaches MIN_RUN_DISTANCE_KM OR their summed MOVING time reaches
|
||||
# WORKOUT_DURATION_ACCEPT_MINUTES. Moving = trackpoint pairs seen within
|
||||
# WALK_MAX_GAP_SECONDS and travelled at least this fast (slower is standing
|
||||
# still). A pair faster than MAX_MOVING_SPEED_MPS (25 km/h, the app's running
|
||||
# jump filter) is a vehicle or a GPS jump and counts neither moving time NOR
|
||||
# distance, for every activity. The Dart app mirrors the bounds.
|
||||
MIN_MOVING_SPEED_MPS = 0.5
|
||||
MAX_MOVING_SPEED_MPS = 25 / 3.6
|
||||
WALK_MAX_GAP_SECONDS = 30
|
||||
# Speed is judged over windows of at least this long (1 s fixes jitter), and a
|
||||
# window longer than WALK_MAX_GAP_SECONDS (a GPS dropout) only counts as
|
||||
# moving time at this speed or faster, so standing through a gap earns nothing.
|
||||
TRACK_WINDOW_SECONDS = 10
|
||||
MIN_DROPOUT_SPEED_MPS = 1.0
|
||||
# Workout records this PC must keep deleted in the sync store, pushed as
|
||||
# tombstones on every tick (see _sync_tombstones for why once is not enough).
|
||||
SYNC_TOMBSTONES_FILE = Path(__file__).resolve().parent / "sync_tombstones.json"
|
||||
|
||||
@ -103,7 +103,7 @@ def send_notification(title: str, body: str) -> bool:
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
_logger.warning("Desktop notification failed (%s): %s", exc, body)
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
if result.returncode:
|
||||
_logger.warning(
|
||||
"notify-send exited %d (%s) — no desktop notification: %s",
|
||||
result.returncode,
|
||||
|
||||
@ -57,7 +57,7 @@ def credit_slot(
|
||||
if with_new == before:
|
||||
# No new slot: either it earns nothing at all, or its slot is taken.
|
||||
return CreditSlot.OCCUPIED if _counts_alone(new_entry) else CreditSlot.NONE
|
||||
return CreditSlot.FIRST if before == 0 else CreditSlot.FURTHER
|
||||
return CreditSlot.FURTHER if before else CreditSlot.FIRST
|
||||
|
||||
|
||||
def _counts_alone(entry: Mapping[str, Any]) -> bool:
|
||||
|
||||
@ -16,7 +16,7 @@ midnight, so a 30-minute earner is applied exactly.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
import functools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@ -26,6 +26,7 @@ import earned_time
|
||||
|
||||
from screen_locker._constants import HMAC_KEY_FILE
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._gate_off import gate_off
|
||||
|
||||
_logger: Final = logging.getLogger(__name__)
|
||||
|
||||
@ -105,12 +106,25 @@ def earned_today(
|
||||
earner: earned_time.Earner, now: datetime | None = None
|
||||
) -> bool | None:
|
||||
"""Whether ``earner``'s gate recorded today's credit; ``None`` if unknown."""
|
||||
return earned_time.done_today(earner, ledger_file(earner), HMAC_KEY_FILE, now=now)
|
||||
day = (now or datetime.now(tz=UTC)).astimezone().date()
|
||||
return earned_time.done_today(
|
||||
earner,
|
||||
ledger_file(earner),
|
||||
HMAC_KEY_FILE,
|
||||
now=now,
|
||||
gate_off=gate_off(earner, day),
|
||||
)
|
||||
|
||||
|
||||
def earned_units(earner: earned_time.Earner, day: date) -> int | None:
|
||||
"""How many of a counted earner's units count for ``day``; ``None`` if unknown."""
|
||||
return earned_time.credit_units(earner, ledger_file(earner), HMAC_KEY_FILE, day)
|
||||
return earned_time.credit_units(
|
||||
earner,
|
||||
ledger_file(earner),
|
||||
HMAC_KEY_FILE,
|
||||
day,
|
||||
gate_off=gate_off(earner, day),
|
||||
)
|
||||
|
||||
|
||||
def gate_answers(day: date | None = None) -> dict[str, int | bool | None]:
|
||||
@ -162,12 +176,6 @@ def extra_minutes(earner: earned_time.Earner, day: date) -> int:
|
||||
return earner.extra_shutdown_minutes if fn is None else int(fn(earner, day))
|
||||
|
||||
|
||||
def ceiling(day: date) -> int:
|
||||
"""The latest shutdown ``day`` can earn, minutes after its midnight."""
|
||||
fn = getattr(earned_time, "shutdown_ceiling_for", None)
|
||||
return earned_time.SHUTDOWN_CEILING_MINUTES if fn is None else int(fn(day))
|
||||
|
||||
|
||||
def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
|
||||
"""Unix time of ``earner``'s first verified credit counting for ``day``.
|
||||
|
||||
@ -179,7 +187,9 @@ def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
|
||||
if fn is None:
|
||||
_warn_no_first_credit()
|
||||
return None
|
||||
stamp: float | None = fn(earner, ledger_file(earner), HMAC_KEY_FILE, day)
|
||||
stamp: float | None = fn(
|
||||
earner, ledger_file(earner), HMAC_KEY_FILE, day, gate_off=gate_off(earner, day)
|
||||
)
|
||||
return stamp
|
||||
|
||||
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
Tracks:
|
||||
- Consecutive weeks with 5+ workouts (streak counter).
|
||||
- ISO weeks in which the early-bird window is extended to 09:00.
|
||||
- ISO weeks in which the early-bird window is extended to 09:00. Display-only
|
||||
bookkeeping: nothing reads it to decide a lock (early bird is wake-alarm's
|
||||
signed exemption since 2026-09-20).
|
||||
- The week-transition reward messages, for the status window.
|
||||
|
||||
There is no weekly shutdown-time bonus (dropped entirely 2026-10-10, hours
|
||||
already banked for 2026-W41 included): every shutdown minute comes from the
|
||||
@ -15,7 +18,7 @@ State is persisted in ``extra_benefits_state.json`` next to this file.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@ -59,7 +62,7 @@ def _save_state(state_file: Path, state: dict[str, Any]) -> None:
|
||||
_logger.warning("Failed to save extra benefits state: %s", exc)
|
||||
|
||||
|
||||
def _current_iso_week(now: datetime) -> str:
|
||||
def _current_iso_week(now: date) -> str:
|
||||
"""Return *now*'s ISO week as ``YYYY-Www``."""
|
||||
year, week, _ = now.isocalendar()
|
||||
return f"{year}-W{week:02d}"
|
||||
@ -100,16 +103,14 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
||||
eb_weeks: list[str] = list(state.get("extended_early_bird_iso_weeks", []))
|
||||
|
||||
rewards: list[str] = []
|
||||
prev_year, prev_week, _ = sunday_prev_week.isocalendar()
|
||||
prev_week_str = f"{prev_year}-W{prev_week:02d}"
|
||||
prev_week_str = _current_iso_week(sunday_prev_week)
|
||||
|
||||
if prev_week_count >= _BONUS_THRESHOLD:
|
||||
streak += 1
|
||||
if current_week_str not in eb_weeks:
|
||||
eb_weeks.append(current_week_str)
|
||||
rewards.append(
|
||||
f"{prev_week_count} workouts in {prev_week_str}! "
|
||||
f"{streak}-week streak, early-bird extended to 09:00 this week"
|
||||
f"{prev_week_count} workouts in {prev_week_str}! {streak}-week streak"
|
||||
)
|
||||
else:
|
||||
if streak > 0:
|
||||
@ -128,6 +129,9 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
||||
"consecutive_5plus_weeks": streak,
|
||||
"last_processed_iso_week": current_week_str,
|
||||
"extended_early_bird_iso_weeks": eb_weeks,
|
||||
# Kept for the status window (display only): the strings above
|
||||
# used to go to the log and nowhere else.
|
||||
"week_rewards": {"iso_week": current_week_str, "messages": rewards},
|
||||
}
|
||||
)
|
||||
_save_state(state_file, updated)
|
||||
@ -147,3 +151,15 @@ def has_extended_early_bird(state_file: Path, *, today: datetime | None = None)
|
||||
"extended_early_bird_iso_weeks", []
|
||||
)
|
||||
return current_week_str in eb_weeks
|
||||
|
||||
|
||||
def week_rewards(state_file: Path, *, today: datetime | None = None) -> tuple[str, ...]:
|
||||
"""The reward messages this ISO week's transition produced, for display.
|
||||
|
||||
Empty once the week has passed, or when nothing was stored.
|
||||
"""
|
||||
now = today if today is not None else datetime.now(tz=UTC).astimezone()
|
||||
stored = _load_state(state_file).get("week_rewards", {})
|
||||
if not isinstance(stored, dict) or stored.get("iso_week") != _current_iso_week(now):
|
||||
return ()
|
||||
return tuple(str(m) for m in stored.get("messages", []))
|
||||
|
||||
28
screen_locker/_gate_off.py
Normal file
28
screen_locker/_gate_off.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""Whether an earner's gate stands down today, asked live (earned_time 0.18.0).
|
||||
|
||||
Only LeetCode has a price: while its gate is on, earned_time pays only the
|
||||
signed charge that covers the whole day, never one solve of five (kuhy,
|
||||
2026-10-24). A free day or a sick day not keeping ``leetcode`` has no price,
|
||||
so one verified solve earns. Never stored: clearing the sick day takes the
|
||||
exception back on the next read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
import freedays
|
||||
import sickdays
|
||||
|
||||
from screen_locker._sick import stands_down
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
|
||||
def gate_off(earner: earned_time.Earner, day: date) -> bool:
|
||||
"""Whether ``earner``'s gate is off on ``day`` (always False but LeetCode)."""
|
||||
if earner.name != earned_time.LEETCODE.name:
|
||||
return False
|
||||
return freedays.is_free_day(day) or stands_down(sickdays.LOCKER_LEETCODE, day)
|
||||
86
screen_locker/_hold.py
Normal file
86
screen_locker/_hold.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""A declared test hold keeps the workout locker from showing any window.
|
||||
|
||||
``holds`` (kuhyx/utils, ``holds-v0.1.0``) is the shared "a proctored test is
|
||||
running, leave the PC usable" switch. Every workout-locker path -- the relaxed
|
||||
day's optional prompt, the strict lock and the early-bird re-check -- runs
|
||||
through ``_startup_checks._check_non_verify_exits``, so one rung there covers
|
||||
them all. It sits BELOW the reset/bonus/ingest/early-bird bookkeeping: a hold
|
||||
only suppresses the window, never a credit or a penalty term.
|
||||
|
||||
Fail closed: if ``holds`` cannot be imported or read, there is no hold and the
|
||||
locker behaves exactly as it did before, with an ERROR in the journal. A hold
|
||||
that fails open would be a free escape from the gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from tkinter import Misc
|
||||
|
||||
__all__ = ["HOLD_REASON", "skip_during_hold", "watch_for_hold", "workout_hold_active"]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
HOLD_REASON = "test_hold_active"
|
||||
_GATE = "workout"
|
||||
# Well inside the 60 s a hold may take to lift an open window.
|
||||
POLL_MS = 30_000
|
||||
|
||||
|
||||
def workout_hold_active(now: datetime | None = None) -> bool:
|
||||
"""Whether a declared hold covers the workout gate right now."""
|
||||
try:
|
||||
# Imported at call time so a missing package fails closed, per call.
|
||||
holds = importlib.import_module("holds")
|
||||
return bool(holds.covers(_GATE, now))
|
||||
except ImportError, OSError, ValueError, TypeError, KeyError:
|
||||
_logger.exception(
|
||||
"Could not read the test holds — treating it as no hold, so "
|
||||
"the workout locker behaves as normal"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def skip_during_hold(record_skip: Callable[..., None]) -> bool:
|
||||
"""Skip enforcement while a hold is active.
|
||||
|
||||
Takes the locker's ``_record_skip`` (which exits the process) rather than
|
||||
the locker, like ``_night_lockdown``. Returns True when the skip was
|
||||
recorded, False when the ladder may go on.
|
||||
"""
|
||||
if not workout_hold_active():
|
||||
return False
|
||||
record_skip(
|
||||
HOLD_REASON,
|
||||
"A declared test hold is active — no workout prompt or lock window "
|
||||
"until it ends (credit and penalty bookkeeping already ran).",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def watch_for_hold(root: Misc, close: Callable[[], None]) -> None:
|
||||
"""Close an already-open window within one poll of a hold starting.
|
||||
|
||||
``close`` is the locker's own ``close`` -- it tears the window down and
|
||||
exits 0 without writing a credit or a penalty, so a hold that interrupts
|
||||
an open prompt/lock changes nothing but the screen. Polls on the Tk loop
|
||||
(no thread) and stops re-arming once it fires.
|
||||
"""
|
||||
|
||||
def _tick() -> None:
|
||||
if workout_hold_active():
|
||||
_logger.warning(
|
||||
"A test hold started while the workout window was open — "
|
||||
"closing it without credit or penalty."
|
||||
)
|
||||
close()
|
||||
return
|
||||
root.after(POLL_MS, _tick)
|
||||
|
||||
root.after(POLL_MS, _tick)
|
||||
@ -10,7 +10,7 @@ app serves the same JSON over ``WORKOUT_HTTP_PORT``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ( # pylint: disable=no-name-in-module
|
||||
from concurrent.futures import (
|
||||
ThreadPoolExecutor,
|
||||
as_completed,
|
||||
)
|
||||
|
||||
@ -19,8 +19,9 @@ import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._ceiling import ceiling
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import ceiling, hhmm
|
||||
from screen_locker._earned import hhmm
|
||||
from screen_locker._grace_floor import save_lift, tonight_minutes
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@ -53,11 +54,13 @@ class LateCreditMixin:
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None: ...
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def _write_shutdown_config(
|
||||
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ...
|
||||
) -> bool: ...
|
||||
) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def _apply_sick_floor(self) -> bool: ...
|
||||
|
||||
|
||||
@ -45,6 +45,9 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from screen_locker._log_mixin import RecordResult
|
||||
from screen_locker._manual_workout import ManualWorkoutDraft
|
||||
|
||||
OnIngestedCallback = Callable[[dict, "list[dict]"], None]
|
||||
|
||||
__all__ = ["ingest_manual_records", "is_empty_stub", "reconstruct_draft"]
|
||||
@ -113,6 +116,76 @@ def _due_date(
|
||||
return date
|
||||
|
||||
|
||||
def _new_valid_draft(
|
||||
log_file: Path,
|
||||
record_id: str,
|
||||
payload: Mapping[str, object],
|
||||
reference: str,
|
||||
) -> tuple[str, ManualWorkoutDraft] | None:
|
||||
"""The due date and valid draft of a not-yet-ingested record, else ``None``."""
|
||||
date = _due_date(record_id, payload, reference)
|
||||
if date is None:
|
||||
return None
|
||||
if _already_ingested(load_workout_log(log_file), record_id):
|
||||
return None
|
||||
draft = _draft_or_report(record_id, payload)
|
||||
if draft is None:
|
||||
return None
|
||||
error = validate_manual_workout(draft)
|
||||
if error is not None:
|
||||
_logger.warning("Manual record %s invalid: %s", record_id, error)
|
||||
return None
|
||||
return date, draft
|
||||
|
||||
|
||||
def _ingest_one(
|
||||
log_file: Path,
|
||||
record_id: str,
|
||||
payload: Mapping[str, object],
|
||||
reference: str,
|
||||
pending: Mapping[str, str],
|
||||
) -> tuple[dict[str, object], RecordResult] | None:
|
||||
"""Validate, budget-check and append one record; ``None`` when skipped."""
|
||||
ready = _new_valid_draft(log_file, record_id, payload, reference)
|
||||
if ready is None:
|
||||
return None
|
||||
date, draft = ready
|
||||
over_budget = budget_error_for_date(
|
||||
log_file,
|
||||
date,
|
||||
[day for rid, day in pending.items() if rid != record_id],
|
||||
)
|
||||
if over_budget is not None:
|
||||
_logger.info(
|
||||
"Manual-workout budget exhausted — %s not ingested (%s)",
|
||||
record_id,
|
||||
over_budget,
|
||||
)
|
||||
return None
|
||||
entry = build_entry(draft)
|
||||
entry[_SYNC_ID_FIELD] = record_id
|
||||
# The wire id IS the workout's id: the device that logged it minted
|
||||
# ``manual:<date>T<start>`` once, and the publisher keys on the same
|
||||
# id. Without this the chokepoint re-derived one from ``date`` +
|
||||
# start, and when the two dates disagreed (a record whose day key was
|
||||
# later corrected to the local day) the same workout landed twice
|
||||
# under two ids -- and then consumed two manual-budget slots.
|
||||
entry["workout_id"] = record_id
|
||||
result = write_signed_entry(log_file, date, entry)
|
||||
if not result.appended:
|
||||
# This is dedup working, not credit being lost: the workout is
|
||||
# already in the log under this same workout_id. It was logged at
|
||||
# warning and re-reported on every 15-minute cycle, which read like
|
||||
# a recurring fault and buried the lines that did need attention.
|
||||
_logger.info(
|
||||
"Manual record %s is already logged under the same workout_id "
|
||||
"— skipped as a duplicate (its credit is already counted)",
|
||||
record_id,
|
||||
)
|
||||
return None
|
||||
return entry, result
|
||||
|
||||
|
||||
def ingest_manual_records(
|
||||
log_file: Path,
|
||||
records: Iterable[tuple[str, Mapping[str, object]]],
|
||||
@ -157,51 +230,10 @@ def ingest_manual_records(
|
||||
pending = pending_future_dates(log_file, batch, today=reference)
|
||||
ingested: list[str] = []
|
||||
for record_id, payload in batch:
|
||||
date = _due_date(record_id, payload, reference)
|
||||
if date is None:
|
||||
continue
|
||||
if _already_ingested(load_workout_log(log_file), record_id):
|
||||
continue
|
||||
draft = _draft_or_report(record_id, payload)
|
||||
if draft is None:
|
||||
continue
|
||||
error = validate_manual_workout(draft)
|
||||
if error is not None:
|
||||
_logger.warning("Manual record %s invalid: %s", record_id, error)
|
||||
continue
|
||||
over_budget = budget_error_for_date(
|
||||
log_file,
|
||||
date,
|
||||
[day for rid, day in pending.items() if rid != record_id],
|
||||
)
|
||||
if over_budget is not None:
|
||||
_logger.info(
|
||||
"Manual-workout budget exhausted — %s not ingested (%s)",
|
||||
record_id,
|
||||
over_budget,
|
||||
)
|
||||
continue
|
||||
entry = build_entry(draft)
|
||||
entry[_SYNC_ID_FIELD] = record_id
|
||||
# The wire id IS the workout's id: the device that logged it minted
|
||||
# ``manual:<date>T<start>`` once, and the publisher keys on the same
|
||||
# id. Without this the chokepoint re-derived one from ``date`` +
|
||||
# start, and when the two dates disagreed (a record whose day key was
|
||||
# later corrected to the local day) the same workout landed twice
|
||||
# under two ids -- and then consumed two manual-budget slots.
|
||||
entry["workout_id"] = record_id
|
||||
result = write_signed_entry(log_file, date, entry)
|
||||
if not result.appended:
|
||||
# This is dedup working, not credit being lost: the workout is
|
||||
# already in the log under this same workout_id. It was logged at
|
||||
# warning and re-reported on every 15-minute cycle, which read like
|
||||
# a recurring fault and buried the lines that did need attention.
|
||||
_logger.info(
|
||||
"Manual record %s is already logged under the same workout_id "
|
||||
"— skipped as a duplicate (its credit is already counted)",
|
||||
record_id,
|
||||
)
|
||||
outcome = _ingest_one(log_file, record_id, payload, reference, pending)
|
||||
if outcome is None:
|
||||
continue
|
||||
entry, result = outcome
|
||||
ingested.append(record_id)
|
||||
if on_ingested is not None:
|
||||
on_ingested(entry, result.prior_entries)
|
||||
|
||||
@ -49,7 +49,7 @@ def is_empty_stub(payload: Mapping[str, object]) -> bool:
|
||||
produced an unbounded stream of identical warnings (four every 15 minutes),
|
||||
which is how the genuinely actionable lines got lost in the noise.
|
||||
"""
|
||||
return not (_SUBSTANTIVE_FIELDS & set(payload))
|
||||
return not _SUBSTANTIVE_FIELDS & set(payload)
|
||||
|
||||
|
||||
def _coerce_int(value: object) -> int:
|
||||
|
||||
@ -20,12 +20,15 @@ import logging
|
||||
|
||||
from screen_locker._constants import (
|
||||
MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS,
|
||||
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
||||
MANUAL_WORKOUT_RPE_MAX,
|
||||
MANUAL_WORKOUT_RPE_MIN,
|
||||
MIN_WORKOUT_DURATION_MINUTES,
|
||||
WORKOUT_DURATION_ACCEPT_MINUTES,
|
||||
)
|
||||
from screen_locker._manual_workout_checks import (
|
||||
validate_reflections,
|
||||
validate_required,
|
||||
)
|
||||
from screen_locker._manual_workout_types import (
|
||||
MANUAL_WORKOUT_TYPE,
|
||||
SPORT_CHOICES,
|
||||
@ -82,19 +85,9 @@ def _duration_minutes(draft: ManualWorkoutDraft) -> float | None:
|
||||
|
||||
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"
|
||||
error = validate_required(draft)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
duration = _duration_minutes(draft)
|
||||
if duration is None:
|
||||
@ -119,18 +112,7 @@ def validate_manual_workout(draft: ManualWorkoutDraft) -> str | None:
|
||||
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
|
||||
return validate_reflections(draft)
|
||||
|
||||
|
||||
def _validate_table_tennis(draft: ManualWorkoutDraft) -> str | None:
|
||||
@ -143,7 +125,7 @@ def _validate_table_tennis(draft: ManualWorkoutDraft) -> str | None:
|
||||
):
|
||||
if value < 0:
|
||||
return f"{label} cannot be negative"
|
||||
if draft.matches_won + draft.matches_lost == 0:
|
||||
if not draft.matches_won + draft.matches_lost:
|
||||
return "Enter at least one match played (won + lost)"
|
||||
if not draft.racket.strip():
|
||||
return "Racket is required"
|
||||
|
||||
44
screen_locker/_manual_workout_checks.py
Normal file
44
screen_locker/_manual_workout_checks.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Field-level checks shared by ``validate_manual_workout``.
|
||||
|
||||
Split out of :mod:`screen_locker._manual_workout` to keep every file under the
|
||||
250-line cap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from screen_locker._constants import MANUAL_WORKOUT_REFLECTION_MIN_CHARS
|
||||
from screen_locker._manual_workout_types import SPORT_CHOICES, ManualWorkoutDraft
|
||||
|
||||
|
||||
def validate_required(draft: ManualWorkoutDraft) -> str | None:
|
||||
"""Return an error if the sport is unchosen or a required field is blank."""
|
||||
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"
|
||||
return None
|
||||
|
||||
|
||||
def validate_reflections(draft: ManualWorkoutDraft) -> str | None:
|
||||
"""Return an error if a reflection field is shorter than the minimum."""
|
||||
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
|
||||
@ -103,8 +103,8 @@ def count_in_window(
|
||||
dated after today are NOT in it. Whether a new entry fits is a different
|
||||
question, answered per workout date by ``_manual_future``.
|
||||
"""
|
||||
today_str = today or _today_iso()
|
||||
today_dt = _parse_iso(today_str)
|
||||
day = today or _today_iso()
|
||||
today_dt = _parse_iso(day)
|
||||
if today_dt is None:
|
||||
return 0
|
||||
cutoff = today_dt - timedelta(days=days)
|
||||
|
||||
@ -28,6 +28,18 @@ _HTTP_OK = _http_client.OK
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _in_current_week(date_str: str) -> bool:
|
||||
"""True if *date_str* parses and falls in this ISO week up to today."""
|
||||
today = datetime.now(tz=UTC).astimezone().date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
try:
|
||||
entry_date = date.fromisoformat(date_str)
|
||||
except ValueError:
|
||||
_logger.warning("StrongLifts JSON has an unparsable date: %r", date_str)
|
||||
return False
|
||||
return week_start <= entry_date <= today
|
||||
|
||||
|
||||
class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
"""Mixin providing phone-based workout verification via ADB."""
|
||||
|
||||
@ -119,16 +131,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
return 0
|
||||
data = self._pull_workout_app_json()
|
||||
date_str = data.get("date") if data else None
|
||||
if not date_str:
|
||||
return 0
|
||||
today = datetime.now(tz=UTC).astimezone().date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
try:
|
||||
entry_date = date.fromisoformat(date_str)
|
||||
except ValueError:
|
||||
_logger.warning("StrongLifts JSON has an unparsable date: %r", date_str)
|
||||
return 0
|
||||
if not week_start <= entry_date <= today:
|
||||
if not date_str or not _in_current_week(date_str):
|
||||
return 0
|
||||
status, msg = self._validate_json_content(data)
|
||||
if status != "verified":
|
||||
@ -145,6 +148,33 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
|
||||
# ── HTTP fallback (no ADB / developer options required) ───────────────────
|
||||
|
||||
def _pull_sync_result(self) -> tuple[tuple[str, str] | None, str | None]:
|
||||
"""Firebase sync first: the validated result (if any) and sync error.
|
||||
|
||||
Firebase sync is the primary channel -- it works without the phone
|
||||
being on the same network as the PC. Only a *verified* result
|
||||
short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the
|
||||
phone's last push predates today, or a later push failed offline)
|
||||
must not preempt a fresher ADB pull, so anything less than
|
||||
"verified" is kept by the caller as a fallback-of-last-resort
|
||||
candidate. A sync error never blocks unlocking either; `sync_failed`
|
||||
only surfaces if ADB/HTTP also come up empty.
|
||||
"""
|
||||
synced_data, sync_error = pull_synced_workout()
|
||||
if synced_data is None:
|
||||
if sync_error is not None:
|
||||
_logger.info(
|
||||
"Firebase sync unavailable (%s) — trying ADB/HTTP...", sync_error
|
||||
)
|
||||
return None, sync_error
|
||||
sync_result = self._validate_json_data(synced_data)
|
||||
if sync_result[0] != "verified":
|
||||
_logger.info(
|
||||
"Synced data not fully verified (%s) — trying ADB/HTTP...",
|
||||
sync_result[0],
|
||||
)
|
||||
return sync_result, sync_error
|
||||
|
||||
def _verify_phone_workout(self) -> tuple[str, str]:
|
||||
"""Verify today's workout: Firebase sync first, ADB/HTTP as fallback.
|
||||
|
||||
@ -156,28 +186,9 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
if not clock_ok:
|
||||
return "clock_tampered", clock_msg
|
||||
|
||||
# Firebase sync is the primary channel — it works without the phone
|
||||
# being on the same network as the PC. Only a *verified* sync result
|
||||
# short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the
|
||||
# phone's last push predates today, or a later push failed offline)
|
||||
# must not preempt a fresher ADB pull, so anything less than
|
||||
# "verified" is kept as a fallback-of-last-resort candidate instead
|
||||
# of being returned immediately. A sync error never blocks unlocking
|
||||
# either; `sync_failed` only surfaces if ADB/HTTP also come up empty.
|
||||
synced_data, sync_error = pull_synced_workout()
|
||||
sync_result: tuple[str, str] | None = None
|
||||
if synced_data is not None:
|
||||
sync_result = self._validate_json_data(synced_data)
|
||||
if sync_result[0] == "verified":
|
||||
return sync_result
|
||||
_logger.info(
|
||||
"Synced data not fully verified (%s) — trying ADB/HTTP...",
|
||||
sync_result[0],
|
||||
)
|
||||
elif sync_error is not None:
|
||||
_logger.info(
|
||||
"Firebase sync unavailable (%s) — trying ADB/HTTP...", sync_error
|
||||
)
|
||||
sync_result, sync_error = self._pull_sync_result()
|
||||
if sync_result is not None and sync_result[0] == "verified":
|
||||
return sync_result
|
||||
|
||||
# Prefer ADB when a device is visible, but if the pull yields no usable
|
||||
# JSON, fall through to the HTTP/WiFi scan — the app's in-memory HTTP
|
||||
@ -194,13 +205,23 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
data = self._fetch_http_workout()
|
||||
if data is not None:
|
||||
return self._validate_json_data(data)
|
||||
if sync_result is not None:
|
||||
return sync_result
|
||||
if sync_error is not None:
|
||||
return "sync_failed", sync_error
|
||||
if adb_connected:
|
||||
return (
|
||||
"not_verified",
|
||||
"Workout app JSON not found. Complete a workout in the app first.",
|
||||
)
|
||||
return "no_phone", "Phone not reachable via ADB or HTTP on local network"
|
||||
return _no_data_result(sync_result, sync_error, adb_connected=adb_connected)
|
||||
|
||||
|
||||
def _no_data_result(
|
||||
sync_result: tuple[str, str] | None,
|
||||
sync_error: str | None,
|
||||
*,
|
||||
adb_connected: bool,
|
||||
) -> tuple[str, str]:
|
||||
"""The verdict when neither ADB nor HTTP produced a workout JSON."""
|
||||
if sync_result is not None:
|
||||
return sync_result
|
||||
if sync_error is not None:
|
||||
return "sync_failed", sync_error
|
||||
if adb_connected:
|
||||
return (
|
||||
"not_verified",
|
||||
"Workout app JSON not found. Complete a workout in the app first.",
|
||||
)
|
||||
return "no_phone", "Phone not reachable via ADB or HTTP on local network"
|
||||
|
||||
@ -30,6 +30,7 @@ from screen_locker._day import today_str
|
||||
from screen_locker._earned import hhmm
|
||||
from screen_locker._extra_benefits import process_week_transition
|
||||
from screen_locker._grace_floor import tonight_minutes
|
||||
from screen_locker._manual_sync import ingest_manual_records
|
||||
from screen_locker._session_sync import ingest_session_records, validate_session
|
||||
from screen_locker._shutdown import read_shutdown_config
|
||||
from screen_locker._shutdown_base import reset_to_base_if_new_day
|
||||
@ -45,6 +46,7 @@ if TYPE_CHECKING:
|
||||
__all__ = [
|
||||
"BatchOutcome",
|
||||
"PokeOutcome",
|
||||
"credit_manual_workouts",
|
||||
"credit_session",
|
||||
"credit_sessions",
|
||||
"current_gaming_minutes",
|
||||
@ -201,11 +203,40 @@ def credit_sessions(
|
||||
results.append(locker.credit_written(entry, prior_entries))
|
||||
|
||||
ingested = ingest_session_records(locker.log_file, records, on_ingested=on_ingested)
|
||||
return _outcome(ingested, results, "session")
|
||||
|
||||
|
||||
def credit_manual_workouts(
|
||||
records: Iterable[tuple[str, Mapping[str, object]]],
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> BatchOutcome:
|
||||
"""``credit_sessions`` for manual workouts, via ``ingest_manual_records``."""
|
||||
locker = fresh_locker()
|
||||
locker.start_the_day()
|
||||
results: list[WorkoutCreditResult] = []
|
||||
|
||||
def on_ingested(
|
||||
entry: dict[str, str], prior_entries: list[dict[str, object]]
|
||||
) -> None:
|
||||
results.append(locker.credit_written(entry, prior_entries))
|
||||
|
||||
ingested = ingest_manual_records(
|
||||
locker.log_file, records, today=today, on_ingested=on_ingested
|
||||
)
|
||||
return _outcome(ingested, results, "manual workout")
|
||||
|
||||
|
||||
def _outcome(
|
||||
ingested: list[str], results: list[WorkoutCreditResult], what: str
|
||||
) -> BatchOutcome:
|
||||
"""Count what earned time; warn about logged copies that earned none."""
|
||||
credited = sum(not result.already_counted_today for result in results)
|
||||
if len(results) > credited:
|
||||
_logger.warning(
|
||||
"%d streamed session(s) were logged but earned no time: today's slot "
|
||||
"%d streamed %s(s) were logged but earned no time: today's slot "
|
||||
"was already paid by another copy of the same workout",
|
||||
len(results) - credited,
|
||||
what,
|
||||
)
|
||||
return BatchOutcome(ingested=tuple(ingested), credited=credited)
|
||||
|
||||
@ -135,7 +135,7 @@ class PokeHandler(BaseHTTPRequestHandler):
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
status, reply = self._handle()
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-exception-caught # handler boundary: logged, answered 500
|
||||
_logger.exception("Workout poke from %s crashed", self.client_address[0])
|
||||
status = HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
reply = _refused(
|
||||
|
||||
@ -15,7 +15,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from screen_locker._log_mixin import write_signed_entry
|
||||
from screen_locker._walk_tcx import walk_day, walk_shortfall
|
||||
from screen_locker._walk_tcx import activity_day, activity_shortfall
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@ -72,66 +72,47 @@ class RunnerUpBackfillMixin:
|
||||
|
||||
Returns True if a new verified entry was appended for ``date_str``.
|
||||
"""
|
||||
walks: list[dict[str, Any]] = []
|
||||
for remote in self._find_runnerup_exports_for_date(date_str):
|
||||
data = self._pull_and_parse_tcx(remote)
|
||||
if data is None:
|
||||
continue
|
||||
if data.get("walking"):
|
||||
# Never one walk at a time: a day's walks are summed below.
|
||||
walks.append(data)
|
||||
continue
|
||||
status, msg = self._validate_runnerup_data(data)
|
||||
if status != "verified":
|
||||
_logger.warning(
|
||||
"RunnerUp export %s for %s did not qualify (%s): %s",
|
||||
remote,
|
||||
date_str,
|
||||
status,
|
||||
msg,
|
||||
)
|
||||
continue
|
||||
workout_data = {
|
||||
"type": "runnerup_verified",
|
||||
"source": f"Auto-scanned: {msg}",
|
||||
"distance_km": round(data["distance_m"] / 1000, 2),
|
||||
"duration_minutes": round(data["duration_seconds"] / 60, 1),
|
||||
"completed_at": data.get("ended_at"),
|
||||
}
|
||||
return _record(log_file, date_str, workout_data, on_ingested)
|
||||
return self._try_fill_walks(date_str, walks, log_file, on_ingested)
|
||||
activities = [
|
||||
data
|
||||
for remote in self._find_runnerup_exports_for_date(date_str)
|
||||
if (data := self._pull_and_parse_tcx(remote)) is not None
|
||||
]
|
||||
return self._try_fill_day(date_str, activities, log_file, on_ingested)
|
||||
|
||||
@staticmethod
|
||||
def _try_fill_walks(
|
||||
def _try_fill_day(
|
||||
date_str: str,
|
||||
walks: list[dict[str, Any]],
|
||||
activities: list[dict[str, Any]],
|
||||
log_file: Path,
|
||||
on_ingested: OnIngestedCallback | None,
|
||||
) -> bool:
|
||||
"""Append the day's walking as its RunnerUp workout, once it adds up.
|
||||
"""Append the day's activity as its RunnerUp workout, once it adds up.
|
||||
|
||||
Same type and so the same ``runnerup_verified:{date}`` slot as a run:
|
||||
a walk and a run on one day are one RunnerUp credit, exactly as two
|
||||
runs already were.
|
||||
One rule for runs and walks alike (see :mod:`screen_locker._walk_tcx`):
|
||||
the day's activities are summed, never judged one file at a time. They
|
||||
share the ``runnerup_verified:{date}`` slot, so a walk and a run on
|
||||
one day are one RunnerUp credit. ``activity: walking`` is kept for the
|
||||
phone's history display only; it never decides credit.
|
||||
"""
|
||||
if not walks:
|
||||
if not activities:
|
||||
return False
|
||||
day = walk_day(walks)
|
||||
day = activity_day(activities)
|
||||
if not day.qualifies:
|
||||
_logger.warning(
|
||||
"RunnerUp walks for %s do not count yet: %s",
|
||||
"RunnerUp activity for %s does not count yet: %s",
|
||||
date_str,
|
||||
walk_shortfall(day),
|
||||
activity_shortfall(day),
|
||||
)
|
||||
return False
|
||||
workout_data = {
|
||||
workout_data: dict[str, Any] = {
|
||||
"type": "runnerup_verified",
|
||||
"activity": "walking",
|
||||
"source": f"Auto-scanned: {day.message()}",
|
||||
"distance_km": round(day.distance_km, 2),
|
||||
"duration_minutes": round(day.moving_minutes, 1),
|
||||
"completed_at": day.completed_at,
|
||||
}
|
||||
if day.all_walks:
|
||||
workout_data["activity"] = "walking"
|
||||
return _record(log_file, date_str, workout_data, on_ingested)
|
||||
|
||||
def _scan_and_fill_week_runnerup(
|
||||
|
||||
215
screen_locker/_runnerup_retire.py
Normal file
215
screen_locker/_runnerup_retire.py
Normal file
@ -0,0 +1,215 @@
|
||||
"""Retire the stock RunnerUp app a week after the PC first credits an app run.
|
||||
|
||||
kuhy's workout_app records runs itself now. Once the PC has credited one of
|
||||
them, RunnerUp is redundant: seven days later (``GRACE``) it is backed up and
|
||||
uninstalled, but only if the app has imported the RunnerUp history (a marker
|
||||
the app writes) and that import matches what ``processed/`` holds. With no
|
||||
anchor (no app run ever credited) it never uninstalls.
|
||||
|
||||
The phone is not always on USB, so ``runnerup-retire.timer`` retries daily
|
||||
until this succeeds. Every outcome is a typed :class:`RetireResult` whose
|
||||
reason is logged at the level it deserves: ``info`` for the normal "not due
|
||||
yet", ``warning`` for a skip that needs attention, ``error`` for a failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._runnerup_retire_adb import PACKAGE, AdbError
|
||||
from screen_locker._runnerup_retire_result import LEVEL, Outcome, RetireResult
|
||||
from screen_locker._runnerup_retire_state import load_state, save_state
|
||||
from screen_locker._runnerup_retire_tcx import app_run_ends, check_marker, find_anchor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from screen_locker._runnerup_retire_adb import PhoneLike
|
||||
from screen_locker._runnerup_retire_state import RetireState
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
GRACE = timedelta(days=7)
|
||||
STATE_FILE = Path.home() / ".local" / "state" / "screen-locker" / "runnerup_retire.json"
|
||||
WATCH_DIR = Path.home() / "data" / "cloud" / "RunnerUp"
|
||||
LOG_FILE = Path(__file__).resolve().parent / "log.json"
|
||||
MARKER_NAME = ".workout_app_import.json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Paths:
|
||||
"""Every on-disk location, so tests (and ``--status``) can redirect them."""
|
||||
|
||||
state_file: Path
|
||||
watch_dir: Path
|
||||
log_file: Path
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> Paths:
|
||||
"""The live locations (read at call time so tests can patch them)."""
|
||||
return cls(STATE_FILE, WATCH_DIR, LOG_FILE)
|
||||
|
||||
@property
|
||||
def processed(self) -> Path:
|
||||
"""Where endurain-import files the RunnerUp exports it has handled."""
|
||||
return self.watch_dir / "processed"
|
||||
|
||||
@property
|
||||
def marker(self) -> Path:
|
||||
"""The app's historic-import marker."""
|
||||
return self.watch_dir / MARKER_NAME
|
||||
|
||||
def anchor_dirs(self) -> tuple[Path, Path]:
|
||||
"""Where an app run's TCX can sit: the drop dir, then ``processed/``."""
|
||||
return (self.watch_dir, self.processed)
|
||||
|
||||
|
||||
def derive_anchor(paths: Paths, state: RetireState) -> datetime | None:
|
||||
"""The stored anchor, else the first credit of an app run found on disk."""
|
||||
return state.anchor or find_anchor(paths.log_file, paths.anchor_dirs())
|
||||
|
||||
|
||||
def _finish(paths: Paths, state: RetireState, now: datetime, why: str) -> None:
|
||||
save_state(
|
||||
paths.state_file, replace(state, done_at=now.isoformat(), done_reason=why)
|
||||
)
|
||||
|
||||
|
||||
def _probe(phone: PhoneLike) -> RetireResult | tuple[str, bool]:
|
||||
"""``(serial, installed)`` if the phone answers, else the result explaining why."""
|
||||
if not phone.available():
|
||||
return RetireResult(
|
||||
Outcome.ADB_MISSING, "adb is not installed (pacman -S android-tools)"
|
||||
)
|
||||
try:
|
||||
device = phone.find_device()
|
||||
if device.serial is None:
|
||||
return RetireResult(
|
||||
Outcome.DEVICE_ABSENT, f"{device.reason}; retrying next run"
|
||||
)
|
||||
return device.serial, phone.installed(device.serial)
|
||||
except AdbError as exc:
|
||||
_logger.exception("RunnerUp retirement: asking the phone failed")
|
||||
return RetireResult(
|
||||
Outcome.PHONE_ERROR, f"asking the phone failed: {exc}", logged=True
|
||||
)
|
||||
|
||||
|
||||
def _backup(
|
||||
paths: Paths, phone: PhoneLike, state: RetireState, serial: str, now: datetime
|
||||
) -> RetireState | RetireResult:
|
||||
"""Back the phone's exports up once; a later retry reuses a finished backup."""
|
||||
if state.backup_path and Path(state.backup_path).is_dir():
|
||||
return state
|
||||
final = paths.watch_dir / f"runnerup_phone_backup_{now:%Y-%m-%d}"
|
||||
try:
|
||||
made, files = phone.backup(serial, final)
|
||||
except (AdbError, OSError) as exc:
|
||||
_logger.exception("RunnerUp retirement: backup failed")
|
||||
return RetireResult(Outcome.BACKUP_FAILED, f"backup failed: {exc}", logged=True)
|
||||
_logger.info("Backed up %d RunnerUp TCX files to %s", files, made)
|
||||
state = replace(state, backup_path=str(made))
|
||||
save_state(paths.state_file, state)
|
||||
return state
|
||||
|
||||
|
||||
def _retire(
|
||||
paths: Paths, phone: PhoneLike, state: RetireState, now: datetime, *, dry_run: bool
|
||||
) -> RetireResult:
|
||||
"""Everything after "it is due and the marker is good": the phone steps."""
|
||||
probed = _probe(phone)
|
||||
if isinstance(probed, RetireResult):
|
||||
return probed
|
||||
serial, present = probed
|
||||
if not present:
|
||||
if not dry_run:
|
||||
_finish(paths, state, now, f"{PACKAGE} was already absent")
|
||||
return RetireResult(Outcome.ALREADY_GONE, f"{PACKAGE} is already absent")
|
||||
if dry_run:
|
||||
return RetireResult(
|
||||
Outcome.DRY_RUN, "would back up and uninstall (no changes made)"
|
||||
)
|
||||
backed = _backup(paths, phone, state, serial, now)
|
||||
if isinstance(backed, RetireResult):
|
||||
return backed
|
||||
try:
|
||||
phone.uninstall(serial)
|
||||
except AdbError as exc:
|
||||
_logger.exception("RunnerUp retirement: uninstall failed")
|
||||
return RetireResult(
|
||||
Outcome.UNINSTALL_FAILED, f"uninstall failed: {exc}", logged=True
|
||||
)
|
||||
_finish(paths, backed, now, f"uninstalled after backup to {backed.backup_path}")
|
||||
return RetireResult(
|
||||
Outcome.UNINSTALLED,
|
||||
f"{PACKAGE} uninstalled; backup in {backed.backup_path}",
|
||||
)
|
||||
|
||||
|
||||
def _no_anchor(paths: Paths) -> RetireResult:
|
||||
"""Why there is no anchor; a missing source is a warning, not "nothing yet"."""
|
||||
absent = [str(p) for p in (paths.watch_dir, paths.log_file) if not p.exists()]
|
||||
if absent:
|
||||
return RetireResult(
|
||||
Outcome.SOURCES_MISSING,
|
||||
f"cannot look for app runs, missing: {', '.join(absent)}",
|
||||
)
|
||||
seen = len(app_run_ends(paths.anchor_dirs()))
|
||||
return RetireResult(
|
||||
Outcome.NO_ANCHOR,
|
||||
f"no app-recorded run credited yet ({seen} app TCX on disk); nothing to retire",
|
||||
)
|
||||
|
||||
|
||||
def _gate(
|
||||
paths: Paths, state: RetireState, now: datetime, *, dry_run: bool
|
||||
) -> RetireState | RetireResult:
|
||||
"""The no-phone checks; a state back means "due, marker good: go on"."""
|
||||
if state.done_at:
|
||||
return RetireResult(
|
||||
Outcome.ALREADY_DONE, f"done {state.done_at}: {state.done_reason}"
|
||||
)
|
||||
anchor = derive_anchor(paths, state)
|
||||
if anchor is None:
|
||||
return _no_anchor(paths)
|
||||
if not state.anchor_at:
|
||||
state = replace(state, anchor_at=anchor.isoformat())
|
||||
if not dry_run:
|
||||
save_state(paths.state_file, state)
|
||||
due = anchor + GRACE
|
||||
if now < due:
|
||||
return RetireResult(
|
||||
Outcome.NOT_DUE, f"due {due:%Y-%m-%d %H:%M} (anchor {anchor:%Y-%m-%d})"
|
||||
)
|
||||
marker = check_marker(paths.marker, paths.processed)
|
||||
if not marker.ok:
|
||||
return RetireResult(
|
||||
Outcome.MARKER_INVALID, f"{marker.reason}; NOT uninstalling"
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def run_retire(
|
||||
paths: Paths,
|
||||
phone: PhoneLike,
|
||||
now: datetime | None = None,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> RetireResult:
|
||||
"""One pass: uninstall RunnerUp if (and only if) every condition holds."""
|
||||
when = now or datetime.now(UTC)
|
||||
gated = _gate(paths, load_state(paths.state_file), when, dry_run=dry_run)
|
||||
if isinstance(gated, RetireResult):
|
||||
result = gated
|
||||
else:
|
||||
result = _retire(paths, phone, gated, when, dry_run=dry_run)
|
||||
if not result.logged:
|
||||
_logger.log(
|
||||
LEVEL.get(result.outcome, logging.INFO),
|
||||
"RunnerUp retirement: %s: %s",
|
||||
result.outcome.value,
|
||||
result.reason,
|
||||
)
|
||||
return result
|
||||
181
screen_locker/_runnerup_retire_adb.py
Normal file
181
screen_locker/_runnerup_retire_adb.py
Normal file
@ -0,0 +1,181 @@
|
||||
"""The phone side of the RunnerUp retirement: find it, back it up, uninstall.
|
||||
|
||||
Every call carries ``-s <serial>`` and goes through one ``run`` seam, so tests
|
||||
never touch a device. adb's exit code is not trusted for the uninstall: it
|
||||
counts only when the output says ``Success`` AND ``pm list packages`` no
|
||||
longer lists the package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING, Any, NoReturn, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
PHONE_SERIAL = "23181JEGR08034"
|
||||
PACKAGE = "org.runnerup.free"
|
||||
REMOTE_DIR = "/sdcard/Documents/RunnerUp"
|
||||
_ADB_TIMEOUT_S = 50
|
||||
# The whole export history (TCX + GPX) over wifi adb; the oneshot unit has no
|
||||
# start timeout, so a slow pull is allowed to finish.
|
||||
_PULL_TIMEOUT_S = 900
|
||||
|
||||
|
||||
class AdbError(RuntimeError):
|
||||
"""An adb call failed; the message says what and why."""
|
||||
|
||||
|
||||
def _fail(message: str) -> NoReturn:
|
||||
raise AdbError(message)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Device:
|
||||
"""Outcome of looking for the phone: the adb serial to use, or why not."""
|
||||
|
||||
serial: str | None
|
||||
reason: str
|
||||
|
||||
|
||||
class PhoneLike(Protocol):
|
||||
"""What the retirement needs from a phone (tests substitute a fake)."""
|
||||
|
||||
def available(self) -> bool:
|
||||
"""Whether adb can be run at all."""
|
||||
|
||||
def find_device(self) -> Device:
|
||||
"""The attached phone, or why there is none."""
|
||||
|
||||
def installed(self, dev: str, *, hidden: bool = True) -> bool:
|
||||
"""Whether RunnerUp is on the phone."""
|
||||
|
||||
def backup(self, dev: str, final: Path) -> tuple[Path, int]:
|
||||
"""Pull the exports to ``final``."""
|
||||
|
||||
def uninstall(self, dev: str) -> None:
|
||||
"""Uninstall RunnerUp or raise ``AdbError``."""
|
||||
|
||||
|
||||
class Phone:
|
||||
"""adb calls against one phone, with the process runner injectable."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
serial: str = PHONE_SERIAL,
|
||||
*,
|
||||
which: Callable[[str], str | None] = shutil.which,
|
||||
run: Callable[..., Any] = subprocess.run,
|
||||
) -> None:
|
||||
"""Pin the phone's serial; ``which``/``run`` are the test seams."""
|
||||
self.serial = serial
|
||||
self._which = which
|
||||
self._run = run
|
||||
|
||||
def available(self) -> bool:
|
||||
"""Whether an ``adb`` binary is on PATH."""
|
||||
return self._which("adb") is not None
|
||||
|
||||
def _adb(self, *args: str, timeout: int = _ADB_TIMEOUT_S) -> str:
|
||||
cmd = ["adb", *args]
|
||||
try:
|
||||
done = self._run(
|
||||
cmd, capture_output=True, text=True, timeout=timeout, check=False
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
msg = f"{' '.join(cmd)} did not run: {exc}"
|
||||
raise AdbError(msg) from exc
|
||||
if done.returncode:
|
||||
detail = (done.stdout + done.stderr).strip()
|
||||
_fail(f"{' '.join(cmd)} exited {done.returncode}: {detail}")
|
||||
return str(done.stdout)
|
||||
|
||||
def _is_phone(self, serial: str) -> bool:
|
||||
"""Whether an ``adb devices`` entry is the phone.
|
||||
|
||||
USB and mDNS serials embed the phone's serial; a plain ``ip:port``
|
||||
wifi entry does not, so that one is asked for its ``ro.serialno``.
|
||||
"""
|
||||
if self.serial in serial:
|
||||
return True
|
||||
if ":" not in serial:
|
||||
return False
|
||||
try:
|
||||
asked = self._adb("-s", serial, "shell", "getprop", "ro.serialno")
|
||||
except AdbError as exc:
|
||||
_logger.warning("Could not read the serial of %s (%s)", serial, exc)
|
||||
return False
|
||||
return asked.strip() == self.serial
|
||||
|
||||
def find_device(self) -> Device:
|
||||
"""The attached phone, over USB or wifi (a wireless serial embeds it)."""
|
||||
listing = self._adb("devices")
|
||||
states: list[str] = []
|
||||
for line in listing.splitlines()[1:]:
|
||||
serial, _, state = line.partition("\t")
|
||||
if not self._is_phone(serial):
|
||||
continue
|
||||
if state.strip() == "device":
|
||||
return Device(serial, "attached")
|
||||
states.append(state.strip())
|
||||
if states:
|
||||
return Device(None, f"phone is {'/'.join(states)}, not ready")
|
||||
return Device(None, f"phone {self.serial} is not attached")
|
||||
|
||||
def installed(self, dev: str, *, hidden: bool = True) -> bool:
|
||||
"""Whether the package is on the phone (exact ``package:`` line).
|
||||
|
||||
``hidden`` adds ``-u`` so an app that focus mode hides or disables
|
||||
still counts as present; it must not be mistaken for an uninstall.
|
||||
"""
|
||||
flag = ["-u"] if hidden else []
|
||||
out = self._adb("-s", dev, "shell", "pm", "list", "packages", *flag, PACKAGE)
|
||||
return f"package:{PACKAGE}" in out.split()
|
||||
|
||||
def backup(self, dev: str, final: Path) -> tuple[Path, int]:
|
||||
"""Pull the phone's export folder next to ``final``; return (dir, tcx count).
|
||||
|
||||
Pulled into a staging dir and renamed only once every phone ``.tcx``
|
||||
is on disk, so a half backup never looks like a finished one. The
|
||||
result is a subfolder, never top-level: a top-level ``*.tcx`` would be
|
||||
credited by runnerup-watch.
|
||||
"""
|
||||
listing = self._adb("-s", dev, "shell", "ls", REMOTE_DIR)
|
||||
wanted = sum(1 for n in listing.split() if n.endswith(".tcx"))
|
||||
staging = final.with_name(final.name + ".partial")
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True)
|
||||
self._adb("-s", dev, "pull", REMOTE_DIR, str(staging), timeout=_PULL_TIMEOUT_S)
|
||||
pulled = staging / Path(REMOTE_DIR).name
|
||||
got = len(list(pulled.rglob("*.tcx"))) if pulled.is_dir() else -1
|
||||
if got < wanted:
|
||||
_fail(f"backup pulled {got} of {wanted} TCX files into {staging}")
|
||||
target, n = final, 1
|
||||
while target.exists():
|
||||
n += 1
|
||||
target = final.with_name(f"{final.name}-{n}")
|
||||
pulled.rename(target)
|
||||
staging.rmdir()
|
||||
return target, got
|
||||
|
||||
def uninstall(self, dev: str) -> None:
|
||||
"""Uninstall the package and prove it is gone, or raise ``AdbError``."""
|
||||
try:
|
||||
out = self._adb("-s", dev, "uninstall", PACKAGE)
|
||||
except AdbError as exc:
|
||||
# adb exits 1 with "Failure [DELETE_FAILED_...]": keep that reason
|
||||
# (a device-owner policy can block it) in the error.
|
||||
msg = f"uninstall refused: {exc}"
|
||||
raise AdbError(msg) from exc
|
||||
if "Success" not in out:
|
||||
_fail(f"uninstall did not report Success: {out.strip()!r}")
|
||||
if self.installed(dev, hidden=False):
|
||||
_fail(f"{PACKAGE} is still listed after uninstall")
|
||||
_logger.info("Uninstalled %s from %s", PACKAGE, dev)
|
||||
62
screen_locker/_runnerup_retire_result.py
Normal file
62
screen_locker/_runnerup_retire_result.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""Outcomes of one RunnerUp-retirement pass, and how loudly each is logged."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
|
||||
__all__ = ["LEVEL", "Outcome", "RetireResult"]
|
||||
|
||||
|
||||
class Outcome(StrEnum):
|
||||
"""How a run ended; the value is what the journal shows."""
|
||||
|
||||
UNINSTALLED = "uninstalled"
|
||||
ALREADY_GONE = "already-gone"
|
||||
ALREADY_DONE = "already-done"
|
||||
NO_ANCHOR = "no-anchor"
|
||||
SOURCES_MISSING = "sources-missing"
|
||||
NOT_DUE = "not-due"
|
||||
DRY_RUN = "dry-run"
|
||||
DEVICE_ABSENT = "device-absent"
|
||||
MARKER_INVALID = "marker-invalid"
|
||||
ADB_MISSING = "adb-missing"
|
||||
PHONE_ERROR = "phone-error"
|
||||
BACKUP_FAILED = "backup-failed"
|
||||
UNINSTALL_FAILED = "uninstall-failed"
|
||||
|
||||
|
||||
# Level per outcome; the failures also make the unit exit non-zero.
|
||||
LEVEL = {
|
||||
Outcome.DEVICE_ABSENT: logging.WARNING,
|
||||
Outcome.SOURCES_MISSING: logging.WARNING,
|
||||
Outcome.MARKER_INVALID: logging.WARNING,
|
||||
Outcome.ADB_MISSING: logging.ERROR,
|
||||
Outcome.PHONE_ERROR: logging.ERROR,
|
||||
Outcome.BACKUP_FAILED: logging.ERROR,
|
||||
Outcome.UNINSTALL_FAILED: logging.ERROR,
|
||||
}
|
||||
_NONZERO = frozenset(
|
||||
{
|
||||
Outcome.MARKER_INVALID,
|
||||
Outcome.ADB_MISSING,
|
||||
Outcome.PHONE_ERROR,
|
||||
Outcome.BACKUP_FAILED,
|
||||
Outcome.UNINSTALL_FAILED,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetireResult:
|
||||
"""What happened and why, in a sentence a human can act on."""
|
||||
|
||||
outcome: Outcome
|
||||
reason: str
|
||||
logged: bool = False # the failure was already logged where it was caught
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""False for outcomes the unit should surface as failed."""
|
||||
return self.outcome not in _NONZERO
|
||||
71
screen_locker/_runnerup_retire_state.py
Normal file
71
screen_locker/_runnerup_retire_state.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""The retirement's own memory: the anchor, the backup, the done stamp.
|
||||
|
||||
One small JSON file (path: ``_runnerup_retire.STATE_FILE``). Written
|
||||
atomically and only by a real run (``--status`` and ``--dry-run`` never write),
|
||||
so the file is also the proof that a run happened. The anchor is stored once
|
||||
and never moved: a later, earlier-looking credit must not push the deadline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetireState:
|
||||
"""What a previous run recorded (all ISO strings, empty until known)."""
|
||||
|
||||
anchor_at: str = ""
|
||||
backup_path: str = ""
|
||||
done_at: str = ""
|
||||
done_reason: str = ""
|
||||
|
||||
@property
|
||||
def anchor(self) -> datetime | None:
|
||||
"""The stored anchor as an aware datetime, or None."""
|
||||
return datetime.fromisoformat(self.anchor_at) if self.anchor_at else None
|
||||
|
||||
|
||||
def load_state(path: Path) -> RetireState:
|
||||
"""Read the state; a missing file is a fresh start, a bad one is loud."""
|
||||
if not path.exists():
|
||||
return RetireState()
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
_logger.warning(
|
||||
"Retire state %s is unreadable (%s): starting from scratch, so the "
|
||||
"anchor is re-derived and the uninstall re-checked",
|
||||
path,
|
||||
exc,
|
||||
)
|
||||
return RetireState()
|
||||
if not isinstance(raw, dict):
|
||||
_logger.warning("Retire state %s is not a JSON object: ignored", path)
|
||||
return RetireState()
|
||||
known = {f.name for f in fields(RetireState)}
|
||||
clean = {k: v for k, v in raw.items() if k in known and isinstance(v, str)}
|
||||
try:
|
||||
state = RetireState(**clean)
|
||||
_ = state.anchor
|
||||
except ValueError as exc:
|
||||
_logger.warning("Retire state %s holds a bad anchor (%s): ignored", path, exc)
|
||||
return RetireState()
|
||||
return state
|
||||
|
||||
|
||||
def save_state(path: Path, state: RetireState) -> None:
|
||||
"""Atomically replace the state file (creates the directory on first write)."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".retire-", suffix=".tmp")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(asdict(state), handle, indent=2)
|
||||
Path(tmp).replace(path)
|
||||
210
screen_locker/_runnerup_retire_tcx.py
Normal file
210
screen_locker/_runnerup_retire_tcx.py
Normal file
@ -0,0 +1,210 @@
|
||||
"""What the RunnerUp retirement reads off disk: app runs, the marker, the anchor.
|
||||
|
||||
Split from :mod:`screen_locker._runnerup_retire` (250-line cap). Everything
|
||||
here is read-only; nothing touches the phone.
|
||||
|
||||
Telling the two sources apart is done on the TCX *content* (``<Author>`` or
|
||||
``<Creator>`` named ``kuhy workout_app``, the app's ``kRunSourceName``), never
|
||||
on the file name: the app may name its files however it likes.
|
||||
|
||||
The anchor is "when the PC first CREDITED an app run". The credit chokepoint
|
||||
(``log.json``) stores a run's TCX end as ``completed_at`` on its
|
||||
``runnerup_verified`` entry and stamps the entry itself with the moment the PC
|
||||
wrote it. So the anchor is the ``timestamp`` of the entry whose
|
||||
``completed_at`` equals an app TCX's last trackpoint. Joining on the end time
|
||||
(not the day) is what makes a same-day RunnerUp run, which fills the day's one
|
||||
credit slot first, correctly yield no anchor for the app run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._runnerup_tcx import parse_tcx_file
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
APP_SOURCE_NAME = "kuhy workout_app"
|
||||
_APP_RE = re.compile(
|
||||
rb"<(?:Author|Creator)\b[^>]*>\s*<Name>\s*"
|
||||
+ re.escape(APP_SOURCE_NAME.encode())
|
||||
+ rb"\s*</Name>"
|
||||
)
|
||||
# The WebDAV copy of a RunnerUp export may carry the device name
|
||||
# (RunnerUp_Pixel_6a_2026-...) that the phone's own copy lacks.
|
||||
_DEVICE_PREFIX = re.compile(r"^RunnerUp_(?:[A-Za-z0-9]+_)*?(?=\d{4}-\d{2}-\d{2}-)")
|
||||
_END_TOLERANCE_S = 1.0
|
||||
|
||||
|
||||
def run_key(name: str) -> str:
|
||||
"""A run's identity: its file name without any device-name prefix."""
|
||||
return _DEVICE_PREFIX.sub("RunnerUp_", name)
|
||||
|
||||
|
||||
def list_tcx(directory: Path) -> list[Path]:
|
||||
"""Finished ``*.tcx`` files directly in ``directory`` (dotfiles are partial)."""
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
p for p in directory.glob("*.tcx") if p.is_file() and not p.name.startswith(".")
|
||||
)
|
||||
|
||||
|
||||
def is_app_tcx(path: Path) -> bool:
|
||||
"""Whether the TCX names the new app as its author or creator."""
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError as exc:
|
||||
_logger.warning(
|
||||
"Cannot read %s (%s): counting it as a RunnerUp file", path, exc
|
||||
)
|
||||
return False
|
||||
return bool(_APP_RE.search(data))
|
||||
|
||||
|
||||
def _ended_at(path: Path) -> float | None:
|
||||
"""The run's end exactly as the credit path computed it (same parser)."""
|
||||
data = parse_tcx_file(str(path))
|
||||
if data is None or data.get("ended_at") is None:
|
||||
_logger.warning("App TCX %s has no usable end time: it cannot anchor", path)
|
||||
return None
|
||||
return float(data["ended_at"])
|
||||
|
||||
|
||||
def _credited_at(entry: dict[str, Any]) -> datetime | None:
|
||||
try:
|
||||
stamp = datetime.fromisoformat(str(entry["timestamp"]))
|
||||
except (KeyError, ValueError) as exc:
|
||||
_logger.warning("Credit entry has no usable timestamp (%r): skipped", exc)
|
||||
return None
|
||||
return stamp if stamp.tzinfo else stamp.astimezone()
|
||||
|
||||
|
||||
def _as_unix(raw: object) -> float | None:
|
||||
"""A unix time from a number or numeric string (the log stores both)."""
|
||||
if isinstance(raw, int | float) and not isinstance(raw, bool):
|
||||
return float(raw)
|
||||
if isinstance(raw, str) and re.fullmatch(r"\d+(\.\d+)?", raw):
|
||||
return float(raw)
|
||||
return None
|
||||
|
||||
|
||||
def _credit_ends(log_file: Path) -> list[tuple[float, datetime]]:
|
||||
"""``(TCX end, PC credit time)`` of every credited run that recorded its end."""
|
||||
found: list[tuple[float, datetime]] = []
|
||||
for entries in load_workout_log(log_file).values():
|
||||
for entry in entries:
|
||||
data = entry.get("workout_data") or {}
|
||||
if data.get("type") != "runnerup_verified":
|
||||
continue
|
||||
end = _as_unix(data.get("completed_at"))
|
||||
if end is None:
|
||||
continue # an older entry that never stored its run's end
|
||||
when = _credited_at(entry)
|
||||
if when is not None:
|
||||
found.append((end, when))
|
||||
return found
|
||||
|
||||
|
||||
def app_run_ends(dirs: Iterable[Path]) -> list[float]:
|
||||
"""The end time of every app-authored TCX that has a usable one."""
|
||||
return [
|
||||
end
|
||||
for directory in dirs
|
||||
for path in list_tcx(directory)
|
||||
if is_app_tcx(path) and (end := _ended_at(path)) is not None
|
||||
]
|
||||
|
||||
|
||||
def find_anchor(log_file: Path, dirs: Iterable[Path]) -> datetime | None:
|
||||
"""When the PC first credited an app-recorded run, or None if it never did."""
|
||||
ends = app_run_ends(dirs)
|
||||
credited = [
|
||||
when
|
||||
for run_end, when in _credit_ends(log_file)
|
||||
if any(abs(run_end - end) <= _END_TOLERANCE_S for end in ends)
|
||||
]
|
||||
return min(credited) if credited else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkerCheck:
|
||||
"""Verdict on the app's historic-import marker."""
|
||||
|
||||
ok: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def _bad(reason: str) -> MarkerCheck:
|
||||
return MarkerCheck(ok=False, reason=reason)
|
||||
|
||||
|
||||
def _marker_names(marker: object) -> tuple[list[str] | None, str]:
|
||||
"""The marker's ``tcx_names`` if its shape is valid, else a reason."""
|
||||
if not isinstance(marker, dict):
|
||||
return None, "marker is not a JSON object"
|
||||
names, count = marker.get("tcx_names"), marker.get("count")
|
||||
if not isinstance(marker.get("completed_at"), str) or not marker["completed_at"]:
|
||||
return None, "marker has no completed_at"
|
||||
if not isinstance(names, list) or not all(isinstance(n, str) for n in names):
|
||||
return None, "marker tcx_names is not a list of names"
|
||||
if isinstance(count, bool) or not isinstance(count, int) or count != len(names):
|
||||
return None, f"marker count {count!r} != {len(names)} listed names"
|
||||
if any("/" in n or n.startswith(".") for n in names):
|
||||
return None, "marker tcx_names must be bare file names"
|
||||
return names, ""
|
||||
|
||||
|
||||
def _read_marker(marker_file: Path) -> tuple[object, str]:
|
||||
"""The parsed marker JSON, or ``(None, why it could not be read)``."""
|
||||
if not marker_file.exists():
|
||||
why = f"{marker_file} is missing: the app has not imported yet"
|
||||
else:
|
||||
try:
|
||||
return json.loads(marker_file.read_text(encoding="utf-8")), ""
|
||||
except (OSError, ValueError) as exc:
|
||||
why = f"{marker_file} is unreadable ({exc})"
|
||||
_logger.warning("Import marker problem: %s", why)
|
||||
return None, why
|
||||
_logger.warning("Import marker problem: %s", why)
|
||||
return None, why
|
||||
|
||||
|
||||
def _compare(names: list[str], processed: Path) -> MarkerCheck:
|
||||
listed = {run_key(n) for n in names}
|
||||
if len(listed) != len(names):
|
||||
return _bad("marker lists the same run more than once")
|
||||
have = {run_key(p.name) for p in list_tcx(processed) if not is_app_tcx(p)}
|
||||
if absent := sorted(listed - have):
|
||||
return _bad(f"{len(absent)} listed run(s) not in processed/: {absent[:3]}")
|
||||
if unlisted := sorted(have - listed):
|
||||
return _bad(
|
||||
f"{len(unlisted)} RunnerUp run(s) in processed/ are not in the "
|
||||
f"marker ({unlisted[:3]}): re-run the app's import"
|
||||
)
|
||||
return MarkerCheck(ok=True, reason=f"{len(listed)} runs match")
|
||||
|
||||
|
||||
def check_marker(marker_file: Path, processed: Path) -> MarkerCheck:
|
||||
"""Validate the app's import marker against what ``processed/`` really holds.
|
||||
|
||||
Valid means: the file parses, ``count`` equals the number of listed names,
|
||||
and the listed runs are exactly the RunnerUp-recorded (non-app) runs in
|
||||
``processed/``. A run is counted once however many copies exist (the
|
||||
device-prefixed WebDAV twin and the phone-named copy are one run).
|
||||
"""
|
||||
marker, why = _read_marker(marker_file)
|
||||
if why:
|
||||
return _bad(why)
|
||||
names, reason = _marker_names(marker)
|
||||
return _compare(names, processed) if names is not None else _bad(reason)
|
||||
@ -65,48 +65,53 @@ class RunnerUpTcxMixin:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
def _parse_tcx(self, tcx_path: str) -> dict[str, Any] | None:
|
||||
"""Parse a local TCX file and return activity summary dict.
|
||||
"""Parse a local TCX file (see :func:`parse_tcx_file`)."""
|
||||
return parse_tcx_file(tcx_path)
|
||||
|
||||
Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so
|
||||
multi-segment runs (pause/resume) are counted in full.
|
||||
"""
|
||||
try:
|
||||
tree = ET.parse(tcx_path)
|
||||
except ET.ParseError as exc:
|
||||
_logger.warning(
|
||||
"TCX file %s is not valid XML (%s) — the run it describes CANNOT "
|
||||
"be verified and will not count",
|
||||
tcx_path,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
root = tree.getroot()
|
||||
activity = root.find(f".//{{{_TCX_NS}}}Activity")
|
||||
if activity is None:
|
||||
_logger.info("No Activity element in TCX file")
|
||||
return None
|
||||
def parse_tcx_file(tcx_path: str) -> dict[str, Any] | None:
|
||||
"""Parse a local TCX file and return activity summary dict.
|
||||
|
||||
sport_str = activity.get("Sport", "")
|
||||
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
|
||||
Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so
|
||||
multi-segment runs (pause/resume) are counted in full.
|
||||
"""
|
||||
try:
|
||||
tree = ET.parse(tcx_path)
|
||||
except ET.ParseError as exc:
|
||||
_logger.warning(
|
||||
"TCX file %s is not valid XML (%s) — the run it describes CANNOT "
|
||||
"be verified and will not count",
|
||||
tcx_path,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
total_seconds = 0.0
|
||||
total_distance = 0.0
|
||||
for lap in activity.findall(f"{{{_TCX_NS}}}Lap"):
|
||||
t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds")
|
||||
d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters")
|
||||
if t_elem is not None and t_elem.text:
|
||||
total_seconds += float(t_elem.text)
|
||||
if d_elem is not None and d_elem.text:
|
||||
total_distance += float(d_elem.text)
|
||||
root = tree.getroot()
|
||||
activity = root.find(f".//{{{_TCX_NS}}}Activity")
|
||||
if activity is None:
|
||||
_logger.info("No Activity element in TCX file")
|
||||
return None
|
||||
|
||||
return {
|
||||
"sport": sport_int,
|
||||
"duration_seconds": int(total_seconds),
|
||||
"distance_m": total_distance,
|
||||
"walking": is_walking_export(Path(tcx_path).name, sport_str),
|
||||
**track_summary(activity),
|
||||
}
|
||||
sport_str = activity.get("Sport", "")
|
||||
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
|
||||
|
||||
total_seconds = 0.0
|
||||
total_distance = 0.0
|
||||
for lap in activity.findall(f"{{{_TCX_NS}}}Lap"):
|
||||
t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds")
|
||||
d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters")
|
||||
if t_elem is not None and t_elem.text:
|
||||
total_seconds += float(t_elem.text)
|
||||
if d_elem is not None and d_elem.text:
|
||||
total_distance += float(d_elem.text)
|
||||
|
||||
return {
|
||||
"sport": sport_int,
|
||||
"duration_seconds": int(total_seconds),
|
||||
"distance_m": total_distance,
|
||||
"walking": is_walking_export(Path(tcx_path).name, sport_str),
|
||||
**track_summary(activity),
|
||||
}
|
||||
|
||||
|
||||
def _mark_walk(data: dict[str, Any] | None, name: str) -> dict[str, Any] | None:
|
||||
|
||||
@ -12,31 +12,26 @@ from typing import Any
|
||||
|
||||
from screen_locker._constants import (
|
||||
MIN_RUN_DISTANCE_KM,
|
||||
MIN_WORKOUT_DURATION_MINUTES,
|
||||
RUNNERUP_DISTANCE_TOLERANCE,
|
||||
RUNNERUP_EXPORT_DIRS,
|
||||
RUNNERUP_WEBDAV_DIRS,
|
||||
WORKOUT_DURATION_ACCEPT_MINUTES,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._runnerup_backfill import RunnerUpBackfillMixin
|
||||
from screen_locker._runnerup_db import RunnerUpDbMixin
|
||||
from screen_locker._runnerup_tcx import RunnerUpTcxMixin
|
||||
from screen_locker._time_check import check_clock_skew
|
||||
from screen_locker._walk_tcx import walk_day, walk_shortfall
|
||||
from screen_locker._walk_tcx import (
|
||||
SPORT_NAMES,
|
||||
activity_day,
|
||||
activity_shortfall,
|
||||
distance_qualifies,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_SPORT_NAMES: dict[int, str] = {
|
||||
0: "Running",
|
||||
1: "Biking",
|
||||
2: "Other",
|
||||
3: "Orienteering",
|
||||
4: "Walking",
|
||||
5: "Treadmill",
|
||||
6: "Gym",
|
||||
7: "Stationary Bike",
|
||||
}
|
||||
# Kept for importers; the labels live next to the one crediting rule.
|
||||
_SPORT_NAMES = SPORT_NAMES
|
||||
|
||||
# TCX uses sport name strings; map back to integer codes for unified validation.
|
||||
|
||||
@ -121,73 +116,46 @@ class RunnerUpVerificationMixin(
|
||||
)
|
||||
return None
|
||||
|
||||
# Try each file; return the best result (verified > validation error).
|
||||
# Walks never qualify one file at a time: they are summed below.
|
||||
best: tuple[str, str] | None = None
|
||||
walks: list[dict[str, Any]] = []
|
||||
for remote in exports:
|
||||
data = self._pull_and_parse_tcx(remote)
|
||||
if data is None:
|
||||
continue
|
||||
if data.get("walking"):
|
||||
walks.append(data)
|
||||
continue
|
||||
status, msg = self._validate_runnerup_data(data)
|
||||
if status == "verified":
|
||||
return status, msg
|
||||
if best is None:
|
||||
best = (status, msg)
|
||||
if walks:
|
||||
day = walk_day(walks)
|
||||
if day.qualifies:
|
||||
return "verified", day.message()
|
||||
best = best or ("too_short", walk_shortfall(day))
|
||||
|
||||
# All files found but none passed validation.
|
||||
return best or (
|
||||
"not_verified",
|
||||
"RunnerUp TCX export found but could not be read",
|
||||
)
|
||||
# One rule for the whole day, whatever the sport or file name: the
|
||||
# day's activities are summed (see _walk_tcx), never judged one file
|
||||
# at a time.
|
||||
activities = [
|
||||
data
|
||||
for remote in exports
|
||||
if (data := self._pull_and_parse_tcx(remote)) is not None
|
||||
]
|
||||
if not activities:
|
||||
return (
|
||||
"not_verified",
|
||||
"RunnerUp TCX export found but could not be read",
|
||||
)
|
||||
day = activity_day(activities)
|
||||
if day.qualifies:
|
||||
return "verified", day.message()
|
||||
return "too_short", activity_shortfall(day)
|
||||
|
||||
def _validate_runnerup_data(self, data: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Validate a RunnerUp activity against configured thresholds.
|
||||
|
||||
Returns ``(status, message)`` following the same contract as
|
||||
``PhoneVerificationMixin._verify_phone_workout``.
|
||||
``PhoneVerificationMixin._verify_phone_workout``. The one rule is
|
||||
:func:`screen_locker._walk_tcx.activity_day`; a dict without
|
||||
``moving_seconds`` (the root-DB path) is judged by its duration.
|
||||
"""
|
||||
sport = data["sport"]
|
||||
|
||||
duration_min = data["duration_seconds"] / 60
|
||||
distance_km = data["distance_m"] / 1000
|
||||
|
||||
# Either criterion qualifies the run on its own (see _constants.py).
|
||||
# Distance gets the GPS tolerance (a measurement correction); duration
|
||||
# gets the shared hidden leeway (a deliberate concession). Different
|
||||
# reasons, so they stay separate constants.
|
||||
distance_ok = (
|
||||
distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) >= MIN_RUN_DISTANCE_KM
|
||||
)
|
||||
duration_ok = duration_min >= WORKOUT_DURATION_ACCEPT_MINUTES
|
||||
if not distance_ok and not duration_ok:
|
||||
msg = (
|
||||
f"Run was {distance_km:.1f} km / {duration_min:.0f} min — need "
|
||||
f"{MIN_RUN_DISTANCE_KM:g}+ km or {MIN_WORKOUT_DURATION_MINUTES}+ min"
|
||||
)
|
||||
return "too_short", msg
|
||||
if distance_ok and distance_km < MIN_RUN_DISTANCE_KM:
|
||||
day = activity_day([data])
|
||||
if not day.qualifies:
|
||||
return "too_short", activity_shortfall(day)
|
||||
if day.distance_km < MIN_RUN_DISTANCE_KM and distance_qualifies(
|
||||
day.distance_km
|
||||
):
|
||||
_logger.info(
|
||||
"RunnerUp distance %.2f km is under the %.1f km minimum but "
|
||||
"within %.0f%% GPS tolerance — accepted.",
|
||||
distance_km,
|
||||
day.distance_km,
|
||||
MIN_RUN_DISTANCE_KM,
|
||||
RUNNERUP_DISTANCE_TOLERANCE * 100,
|
||||
)
|
||||
|
||||
sport_name = _SPORT_NAMES.get(sport, str(sport))
|
||||
return (
|
||||
"verified",
|
||||
f"{sport_name}: {distance_km:.1f} km in {duration_min:.0f} min",
|
||||
)
|
||||
return "verified", day.message()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main entry point
|
||||
|
||||
@ -123,7 +123,7 @@ class SessionStream:
|
||||
except StreamError as exc:
|
||||
_logger.warning("Firebase session stream failed: %s; %s", exc, _FALLBACK)
|
||||
return False
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-exception-caught # daemon boundary: logged, loop reconnects
|
||||
_logger.exception("Firebase session stream crashed; %s", _FALLBACK)
|
||||
return False
|
||||
return True
|
||||
|
||||
@ -9,12 +9,13 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._ceiling import ceiling
|
||||
from screen_locker._constants import (
|
||||
ADJUST_SHUTDOWN_SCRIPT,
|
||||
SHUTDOWN_CONFIG_FILE,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import ceiling, first_minutes, hhmm, span
|
||||
from screen_locker._earned import first_minutes, hhmm, span
|
||||
from screen_locker._grace_floor import plan_absorb, save_lift
|
||||
from screen_locker._late_credit import LateCreditMixin
|
||||
from screen_locker._sick import is_sick
|
||||
@ -31,9 +32,6 @@ _logger = logging.getLogger(__name__)
|
||||
_SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END")
|
||||
_MINUTES_PER_HOUR = 60
|
||||
|
||||
# Extra bonuses may name midnight; the helper still clamps them to 23:00.
|
||||
_MIDNIGHT = 24 * _MINUTES_PER_HOUR
|
||||
|
||||
|
||||
def _parse_config(path: Path) -> dict[str, int]:
|
||||
"""Every integer ``KEY=value`` line of *path*."""
|
||||
@ -65,8 +63,7 @@ def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
|
||||
else:
|
||||
_logger.warning("Shutdown config missing required values")
|
||||
return None
|
||||
mon_wed, thu_sun, morning_end = values
|
||||
return mon_wed, thu_sun, morning_end
|
||||
return values[0], values[1], values[2]
|
||||
|
||||
|
||||
class ShutdownMixin(SickFloorMixin, LateCreditMixin):
|
||||
@ -107,16 +104,17 @@ class ShutdownMixin(SickFloorMixin, LateCreditMixin):
|
||||
def _adjust_shutdown_time_by(
|
||||
self, extra_minutes: int, at: float | None = None
|
||||
) -> bool:
|
||||
"""Push shutdown later by *extra_minutes*, capped at 24:00 (midnight).
|
||||
"""Push shutdown later by *extra_minutes*, capped at the day's ceiling.
|
||||
|
||||
Used for earner and extra-workout bonuses. A cap of midnight works
|
||||
because ``day-specific-shutdown-check.sh`` fires at 00:00 and catches it
|
||||
via the morning-window condition; the helper clamps it to 23:00 anyway.
|
||||
Used for earner and extra-workout bonuses. The cap is
|
||||
``earned_time.shutdown_ceiling_for(today)`` (23:00 today, lower if
|
||||
``WAKE_MINUTES`` ever moves); the helper's 23:00 stays the hard bound.
|
||||
|
||||
``at`` is when the credit was earned (default now): a late one counts
|
||||
from it (:mod:`._late_credit`). Returns True if successful.
|
||||
"""
|
||||
if is_sick(date.fromisoformat(today_str())):
|
||||
day = date.fromisoformat(today_str())
|
||||
if is_sick(day):
|
||||
return self._raise_on_sick_day(extra_minutes, at)
|
||||
late = self._late_write(extra_minutes, at)
|
||||
if late is not None:
|
||||
@ -129,8 +127,8 @@ class ShutdownMixin(SickFloorMixin, LateCreditMixin):
|
||||
return False
|
||||
mw, ts, morning = config_values
|
||||
ok = self._write_shutdown_config(
|
||||
min(_MIDNIGHT, mw + moved),
|
||||
min(_MIDNIGHT, ts + moved),
|
||||
min(ceiling(day), mw + moved),
|
||||
min(ceiling(day), ts + moved),
|
||||
morning,
|
||||
restore=True,
|
||||
)
|
||||
|
||||
@ -48,11 +48,13 @@ class SickFloorMixin:
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None: ...
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def _write_shutdown_config(
|
||||
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ...
|
||||
) -> bool: ...
|
||||
) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def _apply_sick_floor(self) -> bool:
|
||||
"""Raise tonight's shutdown to the sick target if today is sick.
|
||||
|
||||
@ -28,6 +28,7 @@ from screen_locker._decision_log import (
|
||||
)
|
||||
from screen_locker._degraded_sources import degraded_sources
|
||||
from screen_locker._extra_benefits import process_week_transition
|
||||
from screen_locker._hold import skip_during_hold
|
||||
from screen_locker._lock_invariant import refuse_to_lock_over_logged_workout
|
||||
from screen_locker._night_lockdown import skip_after_evening_lockdown
|
||||
from screen_locker._shutdown_base import (
|
||||
@ -125,7 +126,8 @@ class StartupChecksMixin(SyncMixin):
|
||||
# before any early-exit check, so gaps are closed regardless of today's
|
||||
# logged state (early_bird, sick_day, etc.).
|
||||
self._auto_fill_week_runnerup_bonus()
|
||||
if self._check_today_state_exits():
|
||||
# Test hold: no prompt/lock/heat dialog; bookkeeping above is untouched.
|
||||
if self._check_today_state_exits() or skip_during_hold(self._record_skip):
|
||||
return
|
||||
# Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced.
|
||||
if is_relaxed_day():
|
||||
|
||||
@ -71,55 +71,29 @@ def _print_day_line(d: date, entries: list[dict], sick_days: frozenset[date]) ->
|
||||
return any_counted
|
||||
|
||||
|
||||
def run_status(locker: ScreenLocker) -> None:
|
||||
"""Print weekly workout status, run RunnerUp scan, apply bonus, then exit."""
|
||||
today = datetime.now(tz=UTC).astimezone().date()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
log_file: Path = locker.log_file
|
||||
log_data = load_workout_log(log_file)
|
||||
sick_days = workout_standdown_dates()
|
||||
def _scan_runnerup(locker: ScreenLocker, log_file: Path, before_count: int) -> int:
|
||||
"""Run the RunnerUp week scan, print what it filled, return the new count.
|
||||
|
||||
print("=== Weekly Workout Status ===")
|
||||
|
||||
# Per-day breakdown
|
||||
for i in range(7):
|
||||
d = monday + timedelta(days=i)
|
||||
if d > today:
|
||||
break
|
||||
_print_day_line(d, log_data.get(d.isoformat(), []), sick_days)
|
||||
|
||||
print()
|
||||
|
||||
# The weekly total, per the anti-gaming rule (each verified workout counts
|
||||
# individually, manual counts once/day) — NOT a per-day checkmark count,
|
||||
# which would undercount a day holding two verified workouts.
|
||||
before_count = count_weekly_workouts(log_file)
|
||||
|
||||
# RunnerUp scan. Each fill is credited by the shared ingestion callback
|
||||
# (+2h first counted workout of its day, +1h each further one), the same
|
||||
# rule the timer's sync pass and the daily base reset use.
|
||||
Each fill is credited by the shared ingestion callback (+2h first counted
|
||||
workout of its day, +1h each further one), the same rule the timer's sync
|
||||
pass and the daily base reset use.
|
||||
"""
|
||||
n_filled = locker._scan_and_fill_week_runnerup(
|
||||
log_file, on_ingested=locker._credit_ingested_workout
|
||||
)
|
||||
locker.workout_data = {}
|
||||
if n_filled > 0:
|
||||
print(f" Auto-filled {n_filled} workout(s) from RunnerUp exports.")
|
||||
after_count = count_weekly_workouts(log_file)
|
||||
_, thu_sun, _ = locker._read_shutdown_config() or (None, None, None)
|
||||
if thu_sun is not None:
|
||||
print(f" Shutdown tonight: {hhmm(thu_sun)}.")
|
||||
else:
|
||||
if n_filled <= 0:
|
||||
print(" No new workouts found via RunnerUp scan.")
|
||||
after_count = before_count
|
||||
return before_count
|
||||
print(f" Auto-filled {n_filled} workout(s) from RunnerUp exports.")
|
||||
_, thu_sun, _ = locker._read_shutdown_config() or (None, None, None)
|
||||
if thu_sun is not None:
|
||||
print(f" Shutdown tonight: {hhmm(thu_sun)}.")
|
||||
return count_weekly_workouts(log_file)
|
||||
|
||||
print()
|
||||
|
||||
# Extra benefits summary
|
||||
streak = current_streak(EXTRA_BENEFITS_FILE)
|
||||
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
|
||||
eb_str = "Yes — until 09:00" if eb_ext else "No"
|
||||
|
||||
# Heat skips this month
|
||||
def _heat_skips_this_month(log_data: dict[str, list[dict]]) -> str:
|
||||
"""One-line summary of this month's heat skips: count and the latest."""
|
||||
this_month = datetime.now(tz=UTC).astimezone().date().strftime("%Y-%m")
|
||||
heat_entries = [
|
||||
(d, entry)
|
||||
@ -129,21 +103,17 @@ def run_status(locker: ScreenLocker) -> None:
|
||||
if isinstance(entry, dict)
|
||||
and entry.get("workout_data", {}).get("type") == "heat_skip"
|
||||
]
|
||||
if heat_entries:
|
||||
last_date, last_e = max(heat_entries, key=lambda de: de[0])
|
||||
last_temp = last_e.get("workout_data", {}).get("temperature_celsius", "?")
|
||||
heat_str = f"{len(heat_entries)} (last: {last_date}, {last_temp}°C)"
|
||||
else:
|
||||
heat_str = "0"
|
||||
if not heat_entries:
|
||||
return "0"
|
||||
last_date, last_e = max(heat_entries, key=lambda de: de[0])
|
||||
last_temp = last_e.get("workout_data", {}).get("temperature_celsius", "?")
|
||||
return f"{len(heat_entries)} (last: {last_date}, {last_temp}°C)"
|
||||
|
||||
print(f" Streak (5+ wks) : {streak}")
|
||||
print(f" Early-bird extended : {eb_str}")
|
||||
print(f" Heat skips (month) : {heat_str}")
|
||||
print()
|
||||
|
||||
def _print_minimum(after_count: int) -> None:
|
||||
"""Print how far the weekly count is from (or past) the minimum."""
|
||||
remaining = max(0, WEEKLY_WORKOUT_MINIMUM - after_count)
|
||||
extra = max(0, after_count - WEEKLY_WORKOUT_MINIMUM)
|
||||
|
||||
if remaining > 0:
|
||||
print(
|
||||
f" Need {remaining} more to reach the minimum ({WEEKLY_WORKOUT_MINIMUM})."
|
||||
@ -156,13 +126,37 @@ def run_status(locker: ScreenLocker) -> None:
|
||||
f" ({WEEKLY_WORKOUT_MINIMUM}/{WEEKLY_WORKOUT_MINIMUM})."
|
||||
)
|
||||
|
||||
# Shutdown config
|
||||
|
||||
def run_status(locker: ScreenLocker) -> None:
|
||||
"""Print weekly workout status, run RunnerUp scan, apply bonus, then exit."""
|
||||
today = datetime.now(tz=UTC).astimezone().date()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
log_file: Path = locker.log_file
|
||||
log_data = load_workout_log(log_file)
|
||||
sick_days = workout_standdown_dates()
|
||||
|
||||
print("=== Weekly Workout Status ===")
|
||||
for i in range(7):
|
||||
d = monday + timedelta(days=i)
|
||||
if d > today:
|
||||
break
|
||||
_print_day_line(d, log_data.get(d.isoformat(), []), sick_days)
|
||||
print()
|
||||
|
||||
after_count = _scan_runnerup(locker, log_file, count_weekly_workouts(log_file))
|
||||
print()
|
||||
|
||||
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
|
||||
print(f" Streak (5+ wks) : {current_streak(EXTRA_BENEFITS_FILE)}")
|
||||
print(f" Early-bird extended : {'Yes — until 09:00' if eb_ext else 'No'}")
|
||||
print(f" Heat skips (month) : {_heat_skips_this_month(log_data)}")
|
||||
print()
|
||||
|
||||
_print_minimum(after_count)
|
||||
|
||||
cfg = locker._read_shutdown_config()
|
||||
if cfg:
|
||||
_mw, _ts, _morning = cfg
|
||||
print(f" Shutdown tonight : {hhmm(_mw)}")
|
||||
print(f" Shutdown tonight : {hhmm(cfg[0])}")
|
||||
|
||||
# --status is a read-out, not an enforcement run; make that explicit so the
|
||||
# journal never reads as though the locker evaluated and declined to lock.
|
||||
record_no_decision("--status")
|
||||
sys.exit(0)
|
||||
|
||||
@ -48,6 +48,7 @@ from screen_locker._constants import (
|
||||
from screen_locker._extra_benefits import (
|
||||
current_streak,
|
||||
has_extended_early_bird,
|
||||
week_rewards,
|
||||
)
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._morning_session import has_workout_skip_today
|
||||
@ -177,6 +178,7 @@ def gather_status(
|
||||
week=week,
|
||||
streak=current_streak(extra_benefits_file),
|
||||
early_bird_extended=early_bird_extended,
|
||||
week_rewards=week_rewards(extra_benefits_file, today=today_local),
|
||||
shutdown=_shutdown_projection(
|
||||
shutdown_config_file=shutdown_config_file,
|
||||
today_local=today_local,
|
||||
|
||||
@ -89,7 +89,7 @@ def _tonight_minutes(snapshot: StatusSnapshot) -> int | None:
|
||||
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 "…"
|
||||
mark = "…" if week.remaining else "✓"
|
||||
tonight = _tonight_minutes(snapshot)
|
||||
tonight_str = hhmm(tonight) if tonight is not None else "?"
|
||||
sick = " · sick day" if snapshot.today.is_sick_day else ""
|
||||
|
||||
@ -81,6 +81,41 @@ class StatusSectionsMixin:
|
||||
color=self._colors.palette.success,
|
||||
)
|
||||
|
||||
def _section_streak(
|
||||
self, parent: tk.Widget, streak: int, week_rewards: tuple[str, ...] = ()
|
||||
) -> None:
|
||||
"""Render the consecutive 5+-workout-week streak and this week's reward.
|
||||
|
||||
Display only, and exactly the stored values (``extra_benefits_state``):
|
||||
a streak of 0 is said plainly, never padded or rounded up. Nothing here
|
||||
grants time or relaxes a lock. The early-bird extension is not shown:
|
||||
it no longer changes anything (wake-alarm owns early bird).
|
||||
"""
|
||||
del parent
|
||||
self._label("Workout streak", role="body", pad="sm")
|
||||
if streak > 0:
|
||||
self._text(
|
||||
f"🔥 {streak}-week streak (5+ workouts a week)",
|
||||
role="body",
|
||||
color=self._colors.palette.success,
|
||||
)
|
||||
else:
|
||||
self._text(
|
||||
"No streak yet: 5+ workouts in a week starts one.",
|
||||
role="body",
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
for message in week_rewards:
|
||||
# A reset is news, not a celebration: it must not be painted green.
|
||||
lost = message.startswith("Streak reset")
|
||||
self._text(
|
||||
message,
|
||||
role="body",
|
||||
color=self._colors.palette.warning
|
||||
if lost
|
||||
else self._colors.palette.success,
|
||||
)
|
||||
|
||||
def _section_lock_explanation(
|
||||
self, parent: tk.Widget, expl: LockExplanation
|
||||
) -> None:
|
||||
|
||||
@ -86,3 +86,4 @@ class StatusSnapshot:
|
||||
lock_explanation: LockExplanation
|
||||
manual_workout_budget: ManualWorkoutBudgetStatus
|
||||
generated_at: str
|
||||
week_rewards: tuple[str, ...] = ()
|
||||
|
||||
@ -8,7 +8,7 @@ screen's unlock does.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
@ -19,6 +19,7 @@ from screen_locker._day import today_str
|
||||
from screen_locker._earned import extra_minutes, span
|
||||
from screen_locker._status_data import gather_status
|
||||
from screen_locker._weekly_check import WEEKLY_WORKOUT_MINIMUM, count_weekly_workouts
|
||||
from screen_locker.screen_lock import ScreenLocker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@ -26,7 +27,6 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from screen_locker._workout_credit import WorkoutCreditResult
|
||||
from screen_locker.screen_lock import ScreenLocker
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -53,8 +53,6 @@ def _make_bare_verifier(log_file: Path) -> ScreenLocker:
|
||||
``--status`` — just enough state for the verification mixins' 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 = {}
|
||||
|
||||
@ -8,7 +8,8 @@ tombstones ``_manual_push`` re-publishes every tick, and dropping it would
|
||||
let a retracted session be credited again -- but an event that touched only
|
||||
this PC's log never triggers a pass.
|
||||
|
||||
Crediting is :func:`screen_locker._poke_credit.credit_sessions` under the
|
||||
Crediting is :func:`screen_locker._poke_credit.credit_sessions` (and
|
||||
``credit_manual_workouts`` for phone-logged manual workouts) under the
|
||||
same in-process lock as the LAN poke handler. ``write_signed_entry`` already
|
||||
dedups by workout id; the ``seen`` set here only spares the file I/O for
|
||||
records this daemon has already handed to the ingest.
|
||||
@ -23,11 +24,19 @@ from typing import TYPE_CHECKING, Final
|
||||
|
||||
from screen_locker import _poke_credit
|
||||
from screen_locker._rtdb_stream import LOG_LEAF, device_updates
|
||||
from screen_locker._sync_records import _session_records, merge_device_texts
|
||||
from screen_locker._sync_records import (
|
||||
_manual_records,
|
||||
_session_records,
|
||||
merge_device_texts,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
import threading
|
||||
|
||||
from crdt_sync import Hlc
|
||||
|
||||
from screen_locker._poke_credit import BatchOutcome
|
||||
from screen_locker._rtdb_stream import SseEvent
|
||||
|
||||
__all__ = ["DeviceLogCache", "EventResult"]
|
||||
@ -78,15 +87,17 @@ class DeviceLogCache:
|
||||
self._texts[dev] = text
|
||||
return changed
|
||||
|
||||
def _pending(self) -> list[tuple[str, dict[str, object], str]]:
|
||||
"""Union records this daemon has not yet handed to the ingest."""
|
||||
def _pending(
|
||||
self, extract: Callable[[str], dict[str, tuple[dict, Hlc]]], what: str
|
||||
) -> list[tuple[str, dict[str, object], str]]:
|
||||
"""Union ``extract`` records this daemon has not yet handed to the ingest."""
|
||||
union = merge_device_texts(
|
||||
(
|
||||
(f"{self._prefix}/{dev}/{LOG_LEAF}", text)
|
||||
for dev, text in sorted(self._texts.items())
|
||||
),
|
||||
_session_records,
|
||||
"sessions",
|
||||
extract,
|
||||
what,
|
||||
)
|
||||
return [
|
||||
(rid, payload, str(hlc))
|
||||
@ -115,39 +126,62 @@ class DeviceLogCache:
|
||||
snapshot=False,
|
||||
summary=f"only this PC's own log changed ({devices}); no credit",
|
||||
)
|
||||
pending = self._pending()
|
||||
if not pending:
|
||||
sessions = self._pending(_session_records, "sessions")
|
||||
manuals = self._pending(_manual_records, "manual workouts")
|
||||
if not sessions and not manuals:
|
||||
return EventResult(
|
||||
snapshot=full,
|
||||
summary=f"{len(self._texts)} device log(s) cached, changed: {devices}; "
|
||||
f"every session already handed to the ingest -- no-op, no file I/O "
|
||||
f"({_ms(started)} ms)",
|
||||
f"every session and manual workout already handed to the ingest "
|
||||
f"-- no-op, no file I/O ({_ms(started)} ms)",
|
||||
)
|
||||
try:
|
||||
with self._gate:
|
||||
outcome = _poke_credit.credit_sessions(
|
||||
[(rid, payload) for rid, payload, _hlc in pending]
|
||||
)
|
||||
except Exception:
|
||||
done = [
|
||||
_credit(pending, credit, what)
|
||||
for pending, credit, what in (
|
||||
(sessions, _poke_credit.credit_sessions, "session"),
|
||||
(
|
||||
manuals,
|
||||
_poke_credit.credit_manual_workouts,
|
||||
"manual workout",
|
||||
),
|
||||
)
|
||||
if pending
|
||||
]
|
||||
except Exception: # pylint: disable=broad-exception-caught # one bad event must not drop the stream
|
||||
# Not a stream problem: keep the connection (a reconnect would
|
||||
# re-download the snapshot and fail the same way). Nothing is
|
||||
# marked seen, so the next event retries.
|
||||
_logger.exception(
|
||||
"Crediting %d streamed session(s) failed; the next stream event "
|
||||
"retries, and the 15-min workout-sync still covers them",
|
||||
len(pending),
|
||||
"Crediting %d streamed session(s) and %d manual workout(s) failed; "
|
||||
"the next stream event retries, and the 15-min workout-sync still "
|
||||
"covers them",
|
||||
len(sessions),
|
||||
len(manuals),
|
||||
)
|
||||
return EventResult(snapshot=full, summary="crediting failed (see above)")
|
||||
self._seen.update((rid, hlc) for rid, _payload, hlc in pending)
|
||||
self._seen.update((rid, hlc) for rid, _payload, hlc in [*sessions, *manuals])
|
||||
return EventResult(
|
||||
snapshot=full,
|
||||
summary=f"{len(self._texts)} device log(s) cached, changed: {devices}; "
|
||||
f"{len(pending)} session(s) checked, {len(outcome.ingested)} newly "
|
||||
f"logged {list(outcome.ingested)}, {outcome.credited} credited "
|
||||
f"({_ms(started)} ms)",
|
||||
f"{'; '.join(done)} ({_ms(started)} ms)",
|
||||
)
|
||||
|
||||
|
||||
def _credit(
|
||||
pending: list[tuple[str, dict[str, object], str]],
|
||||
credit: Callable[[list[tuple[str, dict[str, object]]]], BatchOutcome],
|
||||
what: str,
|
||||
) -> str:
|
||||
"""Run one batch credit step; describe what it did."""
|
||||
outcome = credit([(rid, payload) for rid, payload, _hlc in pending])
|
||||
return (
|
||||
f"{len(pending)} {what}(s) checked, {len(outcome.ingested)} newly "
|
||||
f"logged {list(outcome.ingested)}, {outcome.credited} credited"
|
||||
)
|
||||
|
||||
|
||||
def _ms(started: float) -> int:
|
||||
"""Milliseconds since ``started`` (a ``perf_counter`` reading)."""
|
||||
return round((time.perf_counter() - started) * 1000)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _manual_workout
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from screen_locker._weekly_check import (
|
||||
RELAXED_DAY_SKIP_TYPE,
|
||||
|
||||
@ -1,41 +1,64 @@
|
||||
"""Walking in RunnerUp TCX exports: which files are walks, and their moving time.
|
||||
"""One crediting rule for every GPS activity, from its RunnerUp-style TCX export.
|
||||
|
||||
A day's walking counts as the workout once its walks -- usually separate
|
||||
commute legs, one file each -- add up to ``WALK_MIN_MOVING_MINUTES`` of
|
||||
*moving* time.
|
||||
A day's activities -- runs and walks, RunnerUp's files and the workout app's
|
||||
alike -- are summed, and the day counts as the workout once EITHER
|
||||
|
||||
**Which files are walks.** RunnerUp's WebDAV and File synchronisers both
|
||||
export with ``ExportOptions.getDefault()``, whose TCX writer knows only
|
||||
Running/Biking and writes ``Sport="Other"`` for a walk. The sport survives
|
||||
only in the file name (``Sport.TapiriikType()``): ``..._Walking.tcx``. The
|
||||
Strava export variant writes ``Sport="Walking"``, so that counts too.
|
||||
``Other`` alone is not a walk: it is every sport RunnerUp cannot name.
|
||||
* their distance reaches ``MIN_RUN_DISTANCE_KM`` (within the GPS tolerance), OR
|
||||
* their *moving* time reaches the shared duration bar
|
||||
(``WORKOUT_DURATION_ACCEPT_MINUTES``).
|
||||
|
||||
The TCX sport and the ``_Walking`` file-name suffix never change the outcome;
|
||||
they only decide the label a message and the log's ``activity`` field carry
|
||||
(the phone's history shows it).
|
||||
|
||||
**Which files are walks (label only).** RunnerUp's WebDAV and File
|
||||
synchronisers both export with ``ExportOptions.getDefault()``, whose TCX writer
|
||||
knows only Running/Biking and writes ``Sport="Other"`` for a walk. The sport
|
||||
survives only in the file name (``Sport.TapiriikType()``): ``..._Walking.tcx``.
|
||||
The Strava export variant writes ``Sport="Walking"``, so that counts too.
|
||||
|
||||
**Moving time, not elapsed.** A lap's ``TotalTimeSeconds`` is wall-clock: one
|
||||
real lap reads 101 min for 1 km of standing about. Moving time is summed over
|
||||
consecutive trackpoint pairs whose speed is walking speed
|
||||
(``WALK_SPEED_MPS``): standing still is too slow, a tram or a bus too fast.
|
||||
A pair further apart than ``WALK_MAX_GAP_SECONDS`` is a GPS dropout whose
|
||||
motion is unknown and earns nothing -- the walk is never credited for time
|
||||
nobody saw.
|
||||
real lap reads 101 min for 1 km of standing about. The track is cut into
|
||||
windows of at least ``TRACK_WINDOW_SECONDS`` (a fix every second jitters, so
|
||||
one raw pair can read 15 m/s inside a 3 m/s run) and each window is judged by
|
||||
its average speed. Faster than ``MAX_MOVING_SPEED_MPS`` (25 km/h) is a vehicle
|
||||
or a GPS jump: neither time nor distance counts, so a bus ride earns nothing.
|
||||
Otherwise its metres count, and its seconds count as moving time at
|
||||
``MIN_MOVING_SPEED_MPS`` or faster; standing still is too slow. A window longer
|
||||
than ``WALK_MAX_GAP_SECONDS`` is a signal dropout (real exports have 2 minute
|
||||
ones): the runner may have kept running unseen, so it counts as moving only at
|
||||
``MIN_DROPOUT_SPEED_MPS`` or faster, while a pause (no distance across the
|
||||
gap) earns nothing -- a pause cannot buy a run its duration.
|
||||
The day's distance is the sum of the windows (``track_distance_m``), never the
|
||||
lap's ``DistanceMeters`` total, and an export with no trackpoints cannot be
|
||||
speed-checked, so it credits nothing. Only the root-DB fallback, which has no
|
||||
trackpoints at all, is judged by its recorded distance and duration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import itertools
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from screen_locker._activity_day import (
|
||||
SPORT_NAMES,
|
||||
ActivityDay,
|
||||
activity_day,
|
||||
activity_shortfall,
|
||||
distance_qualifies,
|
||||
moving_qualifies,
|
||||
)
|
||||
from screen_locker._constants import (
|
||||
MAX_MOVING_SPEED_MPS,
|
||||
MIN_DROPOUT_SPEED_MPS,
|
||||
MIN_MOVING_SPEED_MPS,
|
||||
TRACK_WINDOW_SECONDS,
|
||||
WALK_MAX_GAP_SECONDS,
|
||||
WALK_MIN_MOVING_MINUTES,
|
||||
WALK_SPEED_MPS,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterator
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@ -72,73 +95,78 @@ def _trackpoints(activity: ET.Element) -> list[tuple[float, float]]:
|
||||
return points
|
||||
|
||||
|
||||
def _segments(
|
||||
points: list[tuple[float, float]],
|
||||
) -> Iterator[tuple[float, float]]:
|
||||
"""``(seconds, metres)`` of the track cut into windows of >= TRACK_WINDOW_SECONDS.
|
||||
|
||||
A fix every second jitters (one pair can read 15 m/s inside a 3 m/s run),
|
||||
so speed is judged over a window, never over a raw 1 s pair. Fixes that
|
||||
are already further apart than the window are their own segment.
|
||||
"""
|
||||
if not points:
|
||||
return
|
||||
anchor = points[0]
|
||||
for point in points[1:]:
|
||||
if point[0] - anchor[0] >= TRACK_WINDOW_SECONDS:
|
||||
yield point[0] - anchor[0], point[1] - anchor[1]
|
||||
anchor = point
|
||||
if points[-1] is not anchor:
|
||||
yield points[-1][0] - anchor[0], points[-1][1] - anchor[1]
|
||||
|
||||
|
||||
def track_summary(activity: ET.Element) -> dict[str, Any]:
|
||||
"""Moving seconds and end time of one activity, from its trackpoints."""
|
||||
"""Moving seconds, credited distance and end time, from the trackpoints.
|
||||
|
||||
A segment (see :func:`_segments`) counts unless it was travelled faster
|
||||
than ``MAX_MOVING_SPEED_MPS``: it then adds its metres to
|
||||
``track_distance_m``, and its seconds to ``moving_seconds`` too when it was
|
||||
travelled at least at ``MIN_MOVING_SPEED_MPS``. A segment longer than
|
||||
``WALK_MAX_GAP_SECONDS`` is a signal dropout whose motion is unknown; it
|
||||
only counts as moving time at ``MIN_DROPOUT_SPEED_MPS``, so a pause (no
|
||||
distance across the gap) earns no time while a runner whose GPS went away
|
||||
for two minutes keeps the time and metres the far side proves.
|
||||
"""
|
||||
points = _trackpoints(activity)
|
||||
slow, fast = WALK_SPEED_MPS
|
||||
moving = 0.0
|
||||
for (t0, d0), (t1, d1) in itertools.pairwise(points):
|
||||
gap = t1 - t0
|
||||
if 0 < gap <= WALK_MAX_GAP_SECONDS and slow <= (d1 - d0) / gap <= fast:
|
||||
moving += gap
|
||||
metres = 0.0
|
||||
for seconds, travelled in _segments(points):
|
||||
if seconds <= 0:
|
||||
continue
|
||||
speed = travelled / seconds
|
||||
if speed > MAX_MOVING_SPEED_MPS:
|
||||
continue
|
||||
metres += max(travelled, 0.0)
|
||||
floor = (
|
||||
MIN_MOVING_SPEED_MPS
|
||||
if seconds <= WALK_MAX_GAP_SECONDS
|
||||
else MIN_DROPOUT_SPEED_MPS
|
||||
)
|
||||
if speed >= floor:
|
||||
moving += seconds
|
||||
return {
|
||||
"activity_id": activity.findtext(f"{_TCX_NS}Id") or "",
|
||||
"moving_seconds": moving,
|
||||
"track_distance_m": metres,
|
||||
"ended_at": points[-1][0] if points else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WalkDay:
|
||||
"""A day's walks, summed.
|
||||
|
||||
Attributes:
|
||||
legs: Distinct walks (deduplicated by the activity's ``Id``: the
|
||||
WebDAV copy and the phone's copy are the same walk).
|
||||
moving_minutes: Their summed moving time.
|
||||
distance_km: Their summed distance.
|
||||
completed_at: Unix time the walk that crossed the bar ended -- when
|
||||
the workout was actually done -- or ``None`` if it never did.
|
||||
"""
|
||||
|
||||
legs: int
|
||||
moving_minutes: float
|
||||
distance_km: float
|
||||
completed_at: float | None
|
||||
|
||||
@property
|
||||
def qualifies(self) -> bool:
|
||||
"""Whether the day's walking counts as its workout."""
|
||||
return self.moving_minutes >= WALK_MIN_MOVING_MINUTES
|
||||
|
||||
def message(self) -> str:
|
||||
"""The verification line, in the RunnerUp verifier's own style."""
|
||||
return (
|
||||
f"Walking: {self.legs} walk(s), {self.distance_km:.1f} km, "
|
||||
f"{self.moving_minutes:.0f} min moving"
|
||||
)
|
||||
|
||||
|
||||
def walk_shortfall(day: WalkDay) -> str:
|
||||
"""Why a day's walking did not count yet."""
|
||||
return f"{day.message()} -- need {WALK_MIN_MOVING_MINUTES}+ min moving"
|
||||
|
||||
|
||||
def walk_day(walks: Iterable[dict[str, Any]]) -> WalkDay:
|
||||
"""Sum parsed walk exports (``_parse_tcx`` dicts) into one :class:`WalkDay`."""
|
||||
unique: dict[str, dict[str, Any]] = {}
|
||||
for walk in walks:
|
||||
unique.setdefault(str(walk.get("activity_id") or id(walk)), walk)
|
||||
ordered = sorted(unique.values(), key=lambda w: w.get("ended_at") or 0.0)
|
||||
moving = 0.0
|
||||
completed_at: float | None = None
|
||||
for walk in ordered:
|
||||
moving += float(walk.get("moving_seconds", 0.0))
|
||||
if completed_at is None and moving / 60 >= WALK_MIN_MOVING_MINUTES:
|
||||
completed_at = walk.get("ended_at")
|
||||
return WalkDay(
|
||||
legs=len(ordered),
|
||||
moving_minutes=moving / 60,
|
||||
distance_km=sum(float(w.get("distance_m", 0.0)) for w in ordered) / 1000,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
# The day aggregate lives in ``_activity_day``; these names stay importable here,
|
||||
# along with the walk-only names they replaced.
|
||||
WalkDay = ActivityDay
|
||||
walk_day = activity_day
|
||||
walk_shortfall = activity_shortfall
|
||||
__all__ = [
|
||||
"SPORT_NAMES",
|
||||
"ActivityDay",
|
||||
"WalkDay",
|
||||
"activity_day",
|
||||
"activity_shortfall",
|
||||
"distance_qualifies",
|
||||
"is_walking_export",
|
||||
"moving_qualifies",
|
||||
"track_summary",
|
||||
"walk_day",
|
||||
"walk_shortfall",
|
||||
]
|
||||
|
||||
@ -89,7 +89,7 @@ def _decision_limit(query: str) -> int:
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""Serve the status payloads and the static frontend bundle (read-only)."""
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
def log_message(self, fmt: str, /, *args: object) -> None:
|
||||
"""Route the default request log to ``logging`` at debug level.
|
||||
|
||||
Args:
|
||||
|
||||
@ -223,7 +223,7 @@ def count_weekly_workouts(
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
if not (week_start <= entry_date <= today_date):
|
||||
if entry_date < week_start or entry_date > today_date:
|
||||
continue
|
||||
count += count_day_credits(date_str, entries)
|
||||
return count
|
||||
|
||||
@ -49,9 +49,11 @@ if TYPE_CHECKING:
|
||||
stdout: IO[str] | None
|
||||
returncode: int | None
|
||||
|
||||
def terminate(self) -> None: ...
|
||||
def terminate(self) -> None:
|
||||
"""Ask the process to stop."""
|
||||
|
||||
def wait(self) -> int: ...
|
||||
def wait(self) -> int:
|
||||
"""Block until the process exits; return its exit code."""
|
||||
|
||||
class _Spawner(Protocol):
|
||||
"""``subprocess.Popen``, narrowed to the one call shape used here."""
|
||||
|
||||
@ -183,7 +183,7 @@ class WorkoutCreditMixin:
|
||||
"""
|
||||
try:
|
||||
_credit_notify.notify_workout_credit(before, after, self.log_file)
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-exception-caught # notification must never fail the credit
|
||||
_logger.exception(
|
||||
"Workout credit notification failed — the credit itself is "
|
||||
"applied; only the desktop notification is missing"
|
||||
|
||||
@ -41,7 +41,7 @@ def lock_grab_handoff(lock: LockWindow) -> GrabHandoff:
|
||||
def release() -> None:
|
||||
# Stop first: a watchdog tick between the release and the app's own
|
||||
# grab would take the screen straight back off it.
|
||||
lock._recovery.stop()
|
||||
lock.recovery.stop()
|
||||
# Claim.grab_held false BEFORE the release (fail closed), so
|
||||
# leisure-watch never reads "held" while the workout app has input.
|
||||
# reacquire() -> grab_input() sets it true once the grab lands.
|
||||
|
||||
@ -4,8 +4,8 @@ Every other earner publishes its credits as HMAC-signed rows in a ledger that
|
||||
``earned_time`` reads (``first_credit_at``, ``credit_units``). This is the
|
||||
workout's: ``~/.local/share/workout_locker/ledger.json``, one row per
|
||||
|
||||
* workout credit slot in ``log.json`` -- RunnerUp (a run, or the day's walks
|
||||
summed), StrongLifts (phone or PC session), manual, any lock-screen or
|
||||
* workout credit slot in ``log.json`` -- RunnerUp (the day's runs and walks,
|
||||
summed by one rule), StrongLifts (phone or PC session), manual, any lock-screen or
|
||||
verify path -- appended by the one log write chokepoint
|
||||
(``_log_mixin.write_signed_entry``), so no path can credit without it; and
|
||||
* declared rest day -- appended when it is declared.
|
||||
|
||||
@ -38,9 +38,11 @@ if TYPE_CHECKING:
|
||||
stdout: IO[str] | None
|
||||
returncode: int | None
|
||||
|
||||
def poll(self) -> int | None: ...
|
||||
def poll(self) -> int | None:
|
||||
"""Return the exit code, or ``None`` while it still runs."""
|
||||
|
||||
def terminate(self) -> None: ...
|
||||
def terminate(self) -> None:
|
||||
"""Ask the process to stop."""
|
||||
|
||||
|
||||
__all__ = ["WorkoutSession"]
|
||||
|
||||
@ -69,7 +69,7 @@ def _start_stream(server: PokeServer) -> None:
|
||||
# Same lock as the HTTP handler: a poke and a streamed copy of one
|
||||
# session must never be credited concurrently.
|
||||
SessionStream(server.gate).start()
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-exception-caught # daemon boundary: logged, LAN poke must survive
|
||||
_logger.exception(
|
||||
"Could not start the Firebase session stream; off-LAN workouts will "
|
||||
"only be credited by the 15-min workout-sync until the daemon restarts"
|
||||
|
||||
99
screen_locker/runnerup_retire.py
Normal file
99
screen_locker/runnerup_retire.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""``python3 -m screen_locker.runnerup_retire``: uninstall RunnerUp, a week on.
|
||||
|
||||
What ``runnerup-retire.service`` runs. ``--status`` prints anchor, due date,
|
||||
marker validity and (with ``--query-phone``) whether RunnerUp is installed;
|
||||
it never writes and never talks to the phone unless asked. ``--dry-run`` runs
|
||||
every check, including the read-only adb queries, but writes nothing, pulls
|
||||
nothing and uninstalls nothing. The exit code is non-zero only for outcomes
|
||||
that need a human (bad marker, adb missing, failed backup or uninstall).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._runnerup_retire import (
|
||||
GRACE,
|
||||
Paths,
|
||||
derive_anchor,
|
||||
run_retire,
|
||||
)
|
||||
from screen_locker._runnerup_retire_adb import PHONE_SERIAL, AdbError, Phone
|
||||
from screen_locker._runnerup_retire_state import load_state
|
||||
from screen_locker._runnerup_retire_tcx import check_marker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from screen_locker._runnerup_retire_adb import PhoneLike
|
||||
|
||||
__all__ = ["main", "status_lines"]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _installed_line(phone: PhoneLike, *, query: bool) -> str:
|
||||
if not query:
|
||||
return "installed: not queried (add --query-phone)"
|
||||
try:
|
||||
device = phone.find_device()
|
||||
if device.serial is None:
|
||||
return f"installed: unknown ({device.reason})"
|
||||
return f"installed: {'yes' if phone.installed(device.serial) else 'no'}"
|
||||
except AdbError as exc:
|
||||
_logger.warning("--status could not ask the phone: %s", exc)
|
||||
return f"installed: unknown ({exc})"
|
||||
|
||||
|
||||
def status_lines(
|
||||
paths: Paths, phone: PhoneLike, now: datetime, *, query_phone: bool = False
|
||||
) -> list[str]:
|
||||
"""The ``--status`` report. Read-only: nothing is created or saved."""
|
||||
state = load_state(paths.state_file)
|
||||
anchor = derive_anchor(paths, state)
|
||||
if anchor is None:
|
||||
anchor_line, due_line = "anchor: none (no app run credited yet)", "due: never"
|
||||
else:
|
||||
origin = "stored" if state.anchor_at else "derived, not yet stored"
|
||||
due = anchor + GRACE
|
||||
days = (due - now).total_seconds() / 86400
|
||||
anchor_line = f"anchor: {anchor.isoformat()} ({origin})"
|
||||
due_line = f"due: {due:%Y-%m-%d %H:%M} ({days:+.1f} days)"
|
||||
marker = check_marker(paths.marker, paths.processed)
|
||||
return [
|
||||
anchor_line,
|
||||
due_line,
|
||||
f"marker: {'valid' if marker.ok else 'INVALID'} ({marker.reason})",
|
||||
f"done: {state.done_at or 'no'}",
|
||||
_installed_line(phone, query=query_phone),
|
||||
]
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI entry; returns the process exit code."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", maxsplit=1)[0])
|
||||
parser.add_argument(
|
||||
"--status", action="store_true", help="print state, change nothing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="check everything, change nothing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--query-phone", action="store_true", help="with --status: ask adb"
|
||||
)
|
||||
parser.add_argument("--serial", default=PHONE_SERIAL, help="phone adb serial")
|
||||
args = parser.parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
paths, phone = Paths.default(), Phone(args.serial)
|
||||
if args.status:
|
||||
now = datetime.now(UTC)
|
||||
lines = status_lines(paths, phone, now, query_phone=args.query_phone)
|
||||
sys.stdout.write("\n".join(lines) + "\n")
|
||||
return 0
|
||||
return 0 if run_retire(paths, phone, dry_run=args.dry_run).ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -22,6 +22,7 @@ from gatelock import (
|
||||
|
||||
from screen_locker import _instance
|
||||
from screen_locker._auto_upgrade import AutoUpgradeMixin
|
||||
from screen_locker._carrot_watch import watch_for_carrot
|
||||
from screen_locker._constants import (
|
||||
EARLY_BIRD_END_HOUR,
|
||||
EARLY_BIRD_END_MINUTE,
|
||||
@ -37,6 +38,7 @@ from screen_locker._constants import (
|
||||
from screen_locker._decision_log import record_run_aborted
|
||||
from screen_locker._early_bird import EarlyBirdMixin
|
||||
from screen_locker._heat_skip import HeatSkipMixin
|
||||
from screen_locker._hold import watch_for_hold
|
||||
from screen_locker._log_mixin import LogMixin
|
||||
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
|
||||
from screen_locker._phone_verification import PhoneVerificationMixin
|
||||
@ -208,6 +210,8 @@ class ScreenLocker(
|
||||
# across two separate if/elif/else statements).
|
||||
if self._lock is not None: # pragma: no branch
|
||||
self._lock.grab_input()
|
||||
watch_for_hold(self.root, self.close)
|
||||
watch_for_carrot(self.root, self._stand_down_for_carrot)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the application and exit."""
|
||||
|
||||
@ -115,6 +115,7 @@ class StatusWindow(
|
||||
self._label("Workout Status", role="title", pad="md")
|
||||
self._section_today(self.container, snapshot.today)
|
||||
self._section_week(self.container, snapshot.week)
|
||||
self._section_streak(self.container, snapshot.streak, snapshot.week_rewards)
|
||||
self._section_lock_explanation(self.container, snapshot.lock_explanation)
|
||||
self._section_temperature(self.container)
|
||||
self._section_manual_workout_budget(
|
||||
|
||||
96
screen_locker/tests/_activity_helpers.py
Normal file
96
screen_locker/tests/_activity_helpers.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""Helpers for the unified-activity tests: TCX builder and a phone-less verifier."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from screen_locker._runnerup_verification import RunnerUpVerificationMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
||||
DAY = "2026-10-10"
|
||||
|
||||
|
||||
def tcx(
|
||||
sport: str,
|
||||
start: str,
|
||||
*,
|
||||
moving_min: float,
|
||||
metres: float,
|
||||
pause_min: float = 0.0,
|
||||
) -> str:
|
||||
"""A RunnerUp-shaped export: ``moving_min`` of steady motion covering ``metres``.
|
||||
|
||||
A ``pause_min`` pause sits in the middle of the track as ONE gap between
|
||||
two trackpoints (the clock runs, the distance does not), so the lap's
|
||||
wall-clock time is ``moving_min + pause_min``.
|
||||
"""
|
||||
begin = datetime.fromisoformat(f"{DAY}T{start}:00+00:00")
|
||||
speed = metres / (moving_min * 60)
|
||||
total = int(moving_min * 60)
|
||||
half = total // 2 // 5 * 5
|
||||
pause = int(pause_min * 60)
|
||||
points = ""
|
||||
for s in range(0, total + 1, 5):
|
||||
offset = s + (pause if s > half else 0)
|
||||
points += (
|
||||
f"<Trackpoint><Time>{(begin + timedelta(seconds=offset)).isoformat()}"
|
||||
f"</Time><DistanceMeters>{s * speed:.1f}</DistanceMeters></Trackpoint>"
|
||||
)
|
||||
return (
|
||||
f'<TrainingCenterDatabase xmlns="{_NS}"><Activities>'
|
||||
f'<Activity Sport="{sport}"><Id>{DAY}T{start}</Id><Lap>'
|
||||
f"<TotalTimeSeconds>{total + pause}</TotalTimeSeconds>"
|
||||
f"<DistanceMeters>{metres}</DistanceMeters>"
|
||||
f"<Track>{points}</Track></Lap></Activity></Activities>"
|
||||
"</TrainingCenterDatabase>"
|
||||
)
|
||||
|
||||
|
||||
class Verifier(RunnerUpVerificationMixin):
|
||||
"""The real mixin reading one temporary drop directory and no phone."""
|
||||
|
||||
def __init__(self, folder: Path) -> None:
|
||||
self.folder = folder
|
||||
|
||||
def _has_adb_device(self) -> bool:
|
||||
return False
|
||||
|
||||
def _run_adb(self, args: list[str]) -> tuple[bool, str]:
|
||||
return False, ""
|
||||
|
||||
def _find_runnerup_exports_for_date(self, date_str: str) -> list[str]:
|
||||
return sorted(str(p) for p in self.folder.glob(f"*{date_str}*.tcx"))
|
||||
|
||||
|
||||
def drop(folder: Path, stamp: str, suffix: str, body: str) -> None:
|
||||
"""Write a RunnerUp-named export for the test day."""
|
||||
(folder / f"RunnerUp_Pixel_6a_{DAY}-{stamp}{suffix}.tcx").write_text(body)
|
||||
|
||||
|
||||
def verify(verifier: Verifier) -> tuple[str, str] | None:
|
||||
"""The file-based verdict for the test day."""
|
||||
with patch("screen_locker._runnerup_verification.today_str", return_value=DAY):
|
||||
return verifier._verify_runnerup_via_files()
|
||||
|
||||
|
||||
def tcx_points(sport: str, start: str, points: list[tuple[int, float]]) -> str:
|
||||
"""An export whose track is exactly ``(seconds after start, cumulative metres)``."""
|
||||
begin = datetime.fromisoformat(f"{DAY}T{start}:00+00:00")
|
||||
body = "".join(
|
||||
f"<Trackpoint><Time>{(begin + timedelta(seconds=s)).isoformat()}</Time>"
|
||||
f"<DistanceMeters>{m:.1f}</DistanceMeters></Trackpoint>"
|
||||
for s, m in points
|
||||
)
|
||||
return (
|
||||
f'<TrainingCenterDatabase xmlns="{_NS}"><Activities>'
|
||||
f'<Activity Sport="{sport}"><Id>{DAY}T{start}</Id><Lap>'
|
||||
f"<TotalTimeSeconds>{points[-1][0]}</TotalTimeSeconds>"
|
||||
f"<DistanceMeters>{points[-1][1]}</DistanceMeters>"
|
||||
f"<Track>{body}</Track></Lap></Activity></Activities>"
|
||||
"</TrainingCenterDatabase>"
|
||||
)
|
||||
28
screen_locker/tests/_hold_fakes.py
Normal file
28
screen_locker/tests/_hold_fakes.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""A ``holds`` stand-in for tests: ``covers`` returns a value or raises it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class FakeHolds:
|
||||
"""A ``holds`` stand-in: ``covers`` returns the value, or raises it."""
|
||||
|
||||
def __init__(self, covers: object) -> None:
|
||||
self._covers = covers
|
||||
self.calls: list[tuple[str, datetime | None]] = []
|
||||
|
||||
def covers(self, gate: str, now: datetime | None = None) -> bool:
|
||||
"""Record the call, then answer like ``holds.covers``."""
|
||||
self.calls.append((gate, now))
|
||||
if isinstance(self._covers, BaseException):
|
||||
raise self._covers
|
||||
return bool(self._covers)
|
||||
|
||||
|
||||
def fake_holds_module(*, covers: object) -> FakeHolds:
|
||||
"""A ``holds`` stand-in to install with ``monkeypatch.setitem(sys.modules, ...)``."""
|
||||
return FakeHolds(covers)
|
||||
@ -105,4 +105,20 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"workday_penalty.json",
|
||||
("_constants.WORKDAY_PENALTY_FILE", "_workday_penalty.WORKDAY_PENALTY_FILE"),
|
||||
),
|
||||
# RunnerUp retirement: the anchor/done stamp, the dufs drop directory it
|
||||
# reads the marker and TCX files from (and writes the phone backup into),
|
||||
# and the credit log the anchor is derived from. A test must never see, or
|
||||
# create files under, the user's real RunnerUp folder.
|
||||
(
|
||||
"runnerup_retire.json",
|
||||
("_runnerup_retire.STATE_FILE",),
|
||||
),
|
||||
(
|
||||
"RunnerUp",
|
||||
("_runnerup_retire.WATCH_DIR",),
|
||||
),
|
||||
(
|
||||
"retire_log.json",
|
||||
("_runnerup_retire.LOG_FILE",),
|
||||
),
|
||||
)
|
||||
|
||||
@ -45,8 +45,8 @@ def mock_tk() -> Generator[MagicMock]:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sys_exit() -> Generator[MagicMock]:
|
||||
@pytest.fixture(name="mock_sys_exit")
|
||||
def mock_sys_exit_fixture() -> Generator[MagicMock]:
|
||||
"""Mock sys.exit to prevent test termination."""
|
||||
with patch("screen_locker.screen_lock.sys.exit") as mock:
|
||||
yield mock
|
||||
|
||||
@ -10,8 +10,10 @@ other. ``_isolate_home`` covers paths resolved at *call* time from
|
||||
``Path.home()``; ``_no_free_days_by_default`` covers paths bound into module
|
||||
constants at *import* time, which redirecting home afterwards cannot reach.
|
||||
|
||||
``pre_ladder`` is the one opt-in fixture here: it keeps a test off the real
|
||||
calendar's ladder cutover, the date-dependent half of the same question.
|
||||
``pre_ladder`` and ``leetcode_gate_off`` are the opt-in fixtures here:
|
||||
``pre_ladder`` keeps a test off the real calendar's ladder cutover, the
|
||||
date-dependent half of the same question; ``leetcode_gate_off`` keeps
|
||||
ledger-plumbing tests off LeetCode's paid-in-full rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -22,7 +24,12 @@ import sys
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import freedays._api
|
||||
import pytest
|
||||
import sickdays
|
||||
import sickdays.gui
|
||||
|
||||
import screen_locker._night_lockdown
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@ -42,7 +49,6 @@ def _no_free_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
||||
chain in every test here -- it is the very first check in the chain --
|
||||
and the failures would name early-bird and sick-day logic instead.
|
||||
"""
|
||||
import freedays._api
|
||||
|
||||
monkeypatch.setattr(
|
||||
freedays._api,
|
||||
@ -62,8 +68,6 @@ def _no_sick_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
||||
developer's real today sick. ``sync_quietly`` is stubbed so the button's
|
||||
background push never reaches Firebase.
|
||||
"""
|
||||
import sickdays
|
||||
import sickdays.gui # so its modules are in sys.modules below
|
||||
|
||||
pool = sickdays.Paths.under(tmp_path / "sickdays")
|
||||
# Every sickdays module that bound ``resolve_paths`` by name: the readers
|
||||
@ -172,6 +176,17 @@ def pre_ladder(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("earned_time._ladder.LADDER_FROM", date.max)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def leetcode_gate_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Let a bare LeetCode credit earn, as on a free or stood-down sick day.
|
||||
|
||||
These tests pin the shared ledger plumbing with one credit row per earner.
|
||||
Since earned_time 0.18.0 a LeetCode credit alone earns only while the gate
|
||||
is off; the paid-in-full rule itself is ``test_leetcode_gate_off.py``.
|
||||
"""
|
||||
monkeypatch.setattr("screen_locker._earned.gate_off", lambda _earner, _day: True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_night_lockdown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Never let the lock chain read the machine's real night-lockdown stamp.
|
||||
@ -180,10 +195,39 @@ def _no_real_night_lockdown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
||||
this every enforce-path test would skip the lock on any evening after the
|
||||
real lockdown fired -- and pass in the morning.
|
||||
"""
|
||||
import screen_locker._night_lockdown
|
||||
|
||||
monkeypatch.setattr(
|
||||
screen_locker._night_lockdown,
|
||||
"NIGHT_LOCKDOWN_STAMP",
|
||||
tmp_path / "locked_at_epoch",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_ntp() -> Iterator[None]:
|
||||
"""Never send a real NTP query to pool.ntp.org from a test.
|
||||
|
||||
``check_clock_skew`` (every phone and RunnerUp verification) opens a UDP
|
||||
socket, which ``_block_real_network``'s ``create_connection`` stub cannot
|
||||
reach. A lost reply cost a test the full 5 s ``_NTP_TIMEOUT``, at random.
|
||||
Stubbed as "unreachable", what the real query answers on any OSError.
|
||||
``test_time_check`` drives the real query through its own import.
|
||||
"""
|
||||
with patch("screen_locker._time_check._query_ntp_offset", return_value=None):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_live_focus_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No live focus project reshapes the registry (earned_time >= 0.19)."""
|
||||
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "focus_project"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_holds(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Point ``holds`` at an empty file: a real hold must not mute the ladder.
|
||||
|
||||
Without this the suite would read ``~/.local/share/holds/holds.json`` and
|
||||
every ladder test would start failing inside a declared test hold.
|
||||
"""
|
||||
monkeypatch.setenv("HOLDS_DATA", str(tmp_path / "holds-none.json"))
|
||||
|
||||
137
screen_locker/tests/_retire_helpers.py
Normal file
137
screen_locker/tests/_retire_helpers.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""Builders for the RunnerUp retirement tests: TCX files, marker, credit log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from screen_locker._runnerup_retire import Paths
|
||||
from screen_locker._runnerup_retire_adb import AdbError, Device
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
||||
APP = "kuhy workout_app"
|
||||
# 2026-11-01T10:30:00Z, the end of every TCX built by default.
|
||||
END_UNIX = 1793529000.0
|
||||
|
||||
|
||||
def tcx_text(
|
||||
*, author: str = "RunnerUp", creator: str = "", end: str = "2026-11-01T10:30:00Z"
|
||||
) -> str:
|
||||
"""A tiny TCX: two trackpoints (the last is the run's end) and an author."""
|
||||
creator_xml = f"<Creator><Name>{creator}</Name></Creator>" if creator else ""
|
||||
return (
|
||||
f'<?xml version="1.0"?><TrainingCenterDatabase xmlns="{_NS}"><Activities>'
|
||||
'<Activity Sport="Running"><Id>2026-11-01T10:00:00Z</Id>'
|
||||
"<Lap><TotalTimeSeconds>1800</TotalTimeSeconds>"
|
||||
"<DistanceMeters>5000</DistanceMeters><Track>"
|
||||
"<Trackpoint><Time>2026-11-01T10:00:00Z</Time>"
|
||||
"<DistanceMeters>0</DistanceMeters></Trackpoint>"
|
||||
f"<Trackpoint><Time>{end}</Time><DistanceMeters>5000</DistanceMeters>"
|
||||
f"</Trackpoint></Track></Lap>{creator_xml}</Activity></Activities>"
|
||||
f'<Author xsi:type="Application_t" xmlns:xsi="x"><Name>{author}</Name>'
|
||||
"</Author></TrainingCenterDatabase>"
|
||||
)
|
||||
|
||||
|
||||
def put_tcx(folder: Path, name: str, **kwargs: str) -> Path:
|
||||
"""Write a TCX named ``name`` into ``folder`` (created if needed)."""
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
path = folder / name
|
||||
path.write_text(tcx_text(**kwargs), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def put_marker(watch: Path, names: list[str], count: int | None = None) -> Path:
|
||||
"""Write the app's import marker into the drop folder."""
|
||||
watch.mkdir(parents=True, exist_ok=True)
|
||||
marker = watch / ".workout_app_import.json"
|
||||
body = {
|
||||
"completed_at": "2026-11-02T08:00:00Z",
|
||||
"count": len(names) if count is None else count,
|
||||
"tcx_names": names,
|
||||
}
|
||||
marker.write_text(json.dumps(body), encoding="utf-8")
|
||||
return marker
|
||||
|
||||
|
||||
def put_log(log_file: Path, entries: dict[str, list[dict[str, Any]]]) -> None:
|
||||
"""Write a day-keyed credit log."""
|
||||
log_file.write_text(json.dumps(entries), encoding="utf-8")
|
||||
|
||||
|
||||
def credit(
|
||||
timestamp: str, completed_at: object = None, kind: str = "runnerup_verified"
|
||||
) -> dict[str, Any]:
|
||||
"""One log entry as the credit chokepoint writes it."""
|
||||
data: dict[str, Any] = {"type": kind}
|
||||
if completed_at is not None:
|
||||
data["completed_at"] = completed_at
|
||||
return {"timestamp": timestamp, "workout_data": data}
|
||||
|
||||
|
||||
ANCHOR = datetime(2026, 11, 1, 10, 45, tzinfo=UTC)
|
||||
DUE = ANCHOR + timedelta(days=7)
|
||||
RUN = "RunnerUp_2026-08-12-17-42-11_Running.tcx"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakePhone:
|
||||
"""Records what the retirement asked of the phone."""
|
||||
|
||||
adb: bool = True
|
||||
device: Device = field(default_factory=lambda: Device("S", "attached"))
|
||||
present: bool = True
|
||||
ask_error: AdbError | None = None
|
||||
backup_error: Exception | None = None
|
||||
uninstall_error: AdbError | None = None
|
||||
calls: list[str] = field(default_factory=list)
|
||||
|
||||
def available(self) -> bool:
|
||||
"""Available."""
|
||||
return self.adb
|
||||
|
||||
def find_device(self) -> Device:
|
||||
"""Find device."""
|
||||
if self.ask_error:
|
||||
raise self.ask_error
|
||||
return self.device
|
||||
|
||||
def installed(self, dev: str, *, hidden: bool = True) -> bool:
|
||||
"""Installed."""
|
||||
self.calls.append(f"installed {dev}")
|
||||
return self.present
|
||||
|
||||
def backup(self, dev: str, final: Path) -> tuple[Path, int]:
|
||||
"""Backup."""
|
||||
self.calls.append("backup")
|
||||
if self.backup_error:
|
||||
raise self.backup_error
|
||||
final.mkdir(parents=True)
|
||||
return final, 3
|
||||
|
||||
def uninstall(self, dev: str) -> None:
|
||||
"""Uninstall."""
|
||||
self.calls.append("uninstall")
|
||||
if self.uninstall_error:
|
||||
raise self.uninstall_error
|
||||
|
||||
|
||||
def world(tmp_path: Path, *, anchored: bool = True, marker: bool = True) -> Paths:
|
||||
"""A drop folder with one RunnerUp run, one app run, and the credit log."""
|
||||
paths = Paths(
|
||||
tmp_path / "state" / "s.json", tmp_path / "drop", tmp_path / "log.json"
|
||||
)
|
||||
put_tcx(paths.processed, RUN)
|
||||
put_tcx(paths.processed, "app.tcx", author=APP)
|
||||
put_log(
|
||||
paths.log_file,
|
||||
{"d": [credit(ANCHOR.isoformat(), END_UNIX)]} if anchored else {},
|
||||
)
|
||||
if marker:
|
||||
put_marker(paths.watch_dir, [RUN])
|
||||
return paths
|
||||
@ -19,8 +19,9 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker import _sync_client
|
||||
from screen_locker._degraded_sources import clear_degraded_sources
|
||||
from screen_locker._earned import flat_earners
|
||||
from screen_locker._earned import watched_earners
|
||||
from screen_locker.tests._gatelock_fixtures import (
|
||||
FAKE_OUTPUTS,
|
||||
TWO_OUTPUTS,
|
||||
@ -37,7 +38,7 @@ from screen_locker.tests._locker_factories import (
|
||||
)
|
||||
from screen_locker.tests._opt_in_fixtures import (
|
||||
_mock_sys_exit,
|
||||
mock_sys_exit,
|
||||
mock_sys_exit_fixture,
|
||||
mock_tk,
|
||||
temp_log_file,
|
||||
)
|
||||
@ -61,7 +62,7 @@ __all__ = [
|
||||
"create_locker_early_bird",
|
||||
"create_locker_relaxed_day",
|
||||
"dual_output",
|
||||
"mock_sys_exit",
|
||||
"mock_sys_exit_fixture",
|
||||
"mock_tk",
|
||||
"temp_log_file",
|
||||
]
|
||||
@ -153,7 +154,7 @@ def _block_real_network() -> Iterator[None]:
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_degraded_sources() -> Iterator[None]:
|
||||
def _clear_degraded_sources() -> None:
|
||||
"""Reset the process-scoped degraded-backend registry around every test.
|
||||
|
||||
``_degraded_sources._degraded`` is a module-level list, so a test that
|
||||
@ -162,7 +163,6 @@ def _clear_degraded_sources() -> Iterator[None]:
|
||||
the local venvs do not) exposes it -- it failed on push and nowhere else.
|
||||
"""
|
||||
clear_degraded_sources()
|
||||
return
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -183,8 +183,8 @@ def _isolate_state_files(tmp_path: Path) -> Iterator[None]:
|
||||
yield
|
||||
|
||||
|
||||
# Every registered flat earner's live-pass stamp, so a future one is seeded too.
|
||||
_SEEDED_STAMPS = ("last_reset_date", *(f"{e.name}_bonus_date" for e in flat_earners()))
|
||||
# Every registry's flat earners (a live focus project hides some today).
|
||||
_BONUS = [f"{e.name}_bonus_date" for e in watched_earners() if e.kind == "flat"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -200,7 +200,7 @@ def _isolate_shutdown_base(tmp_path: Path) -> Iterator[None]:
|
||||
"""
|
||||
target = tmp_path / "shutdown_base.json"
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
target.write_text(json.dumps(dict.fromkeys(_SEEDED_STAMPS, today)))
|
||||
target.write_text(json.dumps(dict.fromkeys(["last_reset_date", *_BONUS], today)))
|
||||
with (
|
||||
patch("screen_locker._constants.SHUTDOWN_BASE_FILE", target),
|
||||
patch("screen_locker._startup_checks.SHUTDOWN_BASE_FILE", target),
|
||||
@ -245,6 +245,5 @@ def _no_real_firebase_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""
|
||||
# Patch _sync_client's binding: crdt_sync's own CONFIG_FILE is bound to
|
||||
# the real home at import, which _isolate_home cannot redirect.
|
||||
from screen_locker import _sync_client
|
||||
|
||||
monkeypatch.setattr(_sync_client, "CONFIG_FILE", Path("/nonexistent/firebase.json"))
|
||||
|
||||
@ -8,16 +8,16 @@
|
||||
"threshold, change it here and watch both suites fail together.",
|
||||
"",
|
||||
"advertised_minutes is the ONLY number that may appear in a user-facing",
|
||||
"message. accept_minutes is deliberately invisible: a 35-minute session",
|
||||
"passes while every string still says 40. Do not advertise accept_minutes."
|
||||
"message. accept_minutes is deliberately invisible: a 25-minute session",
|
||||
"passes while every string still says 30. Do not advertise accept_minutes."
|
||||
],
|
||||
"advertised_minutes": 40,
|
||||
"advertised_minutes": 30,
|
||||
"leeway_minutes": 5,
|
||||
"accept_minutes": 35,
|
||||
"accept_minutes": 25,
|
||||
"cases": [
|
||||
{ "duration_minutes": 34, "accepted": false },
|
||||
{ "duration_minutes": 35, "accepted": true },
|
||||
{ "duration_minutes": 40, "accepted": true },
|
||||
{ "duration_minutes": 24, "accepted": false },
|
||||
{ "duration_minutes": 25, "accepted": true },
|
||||
{ "duration_minutes": 30, "accepted": true },
|
||||
{ "duration_minutes": 60, "accepted": true }
|
||||
]
|
||||
}
|
||||
|
||||
190
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-08-12-17-42-11_Running.tcx
vendored
Normal file
190
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-08-12-17-42-11_Running.tcx
vendored
Normal file
@ -0,0 +1,190 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:ns3="http://www.garmin.com/xmlschemas/ActivityExtension/v2" xsi:schemaLocation="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd">
|
||||
<Activities>
|
||||
<Activity Sport="Running">
|
||||
<Id>2026-08-12T15:42:11Z</Id>
|
||||
<Lap StartTime="2026-08-12T15:42:11Z">
|
||||
<TotalTimeSeconds>624</TotalTimeSeconds>
|
||||
<DistanceMeters>983.6306355595589</DistanceMeters>
|
||||
<Calories>0</Calories>
|
||||
<Intensity>Active</Intensity>
|
||||
<TriggerMethod>Manual</TriggerMethod>
|
||||
<Track>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:12Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858438076</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294584871</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>110.06611511572041</AltitudeMeters>
|
||||
<DistanceMeters>2.142966628074646</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>54</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:13Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858442867</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294574705</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>109.65041623678366</AltitudeMeters>
|
||||
<DistanceMeters>3.0187163949012756</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>53</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:37Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858578090</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294260282</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>102.71014718222068</AltitudeMeters>
|
||||
<DistanceMeters>31.116633534431458</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>65</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:48Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858744365</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294188828</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>101.26072111921606</AltitudeMeters>
|
||||
<DistanceMeters>50.64840912818909</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>61</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:56Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858792955</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294100028</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>100.58585072961351</AltitudeMeters>
|
||||
<DistanceMeters>58.97427588701248</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>58</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:43:42Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858859857</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.292925949</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>99.60869064520372</AltitudeMeters>
|
||||
<DistanceMeters>140.32738709449768</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>57</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
</Track>
|
||||
</Lap>
|
||||
|
||||
<Lap StartTime="2026-08-12T15:52:36Z">
|
||||
<TotalTimeSeconds>626</TotalTimeSeconds>
|
||||
<DistanceMeters>980.7304027080536</DistanceMeters>
|
||||
<Calories>0</Calories>
|
||||
<Intensity>Active</Intensity>
|
||||
<TriggerMethod>Manual</TriggerMethod>
|
||||
<Track>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:52:36Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.859968549</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.282023519</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>99.76934097304894</AltitudeMeters>
|
||||
<DistanceMeters>1003.5486820391379</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>59</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:52:37Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.859990492</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.282025531</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>100.06562741186055</AltitudeMeters>
|
||||
<DistanceMeters>1005.9941445044242</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>57</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:54:28Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.861645306</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.281974721</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>99.66943152941295</AltitudeMeters>
|
||||
<DistanceMeters>1190.9687945595942</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>55</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:54:46Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.861872213</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.282046494</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>99.35286804810477</AltitudeMeters>
|
||||
<DistanceMeters>1217.0878333947621</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>58</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
</Track>
|
||||
</Lap>
|
||||
<Notes></Notes>
|
||||
<Creator xsi:type="Device_t">
|
||||
<Name>RunnerUp Pixel 6a with barometer</Name>
|
||||
<UnitId>0</UnitId>
|
||||
<ProductID>0</ProductID>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
<BuildMajor>0</BuildMajor>
|
||||
<BuildMinor>0</BuildMinor>
|
||||
</Version>
|
||||
</Creator>
|
||||
</Activity>
|
||||
</Activities>
|
||||
<Author xsi:type="Application_t">
|
||||
<Name>org.runnerup - 2.11.0.1 - 23000372</Name>
|
||||
<Build>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Build>
|
||||
<LangID>en</LangID>
|
||||
<PartNumber>000-00000-00</PartNumber>
|
||||
</Author>
|
||||
</TrainingCenterDatabase>
|
||||
1072
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-09-15-00_Walking.tcx
vendored
Normal file
1072
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-09-15-00_Walking.tcx
vendored
Normal file
File diff suppressed because it is too large
Load Diff
748
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-17-30-00_Running.tcx
vendored
Normal file
748
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-17-30-00_Running.tcx
vendored
Normal file
@ -0,0 +1,748 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd">
|
||||
<Activities>
|
||||
<Activity Sport="Running">
|
||||
<Id>2026-11-09T16:30:00Z</Id>
|
||||
<Lap StartTime="2026-11-09T16:30:00Z">
|
||||
<TotalTimeSeconds>1560</TotalTimeSeconds>
|
||||
<DistanceMeters>4212.014</DistanceMeters>
|
||||
<Calories>0</Calories>
|
||||
<Intensity>Active</Intensity>
|
||||
<TriggerMethod>Manual</TriggerMethod>
|
||||
<Track>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:30:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8584000</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.0</AltitudeMeters>
|
||||
<DistanceMeters>0.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:30:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8587720</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2949745</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.0</AltitudeMeters>
|
||||
<DistanceMeters>54.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:30:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8591285</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2954757</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.3</AltitudeMeters>
|
||||
<DistanceMeters>107.999</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:31:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8594688</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2960023</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.5</AltitudeMeters>
|
||||
<DistanceMeters>161.998</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:31:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8597926</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2965524</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.8</AltitudeMeters>
|
||||
<DistanceMeters>215.997</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:31:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8600999</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2971240</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.1</AltitudeMeters>
|
||||
<DistanceMeters>269.998</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:32:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8603909</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2977150</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.3</AltitudeMeters>
|
||||
<DistanceMeters>324.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:32:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8606663</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2983230</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.6</AltitudeMeters>
|
||||
<DistanceMeters>378.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:32:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8609269</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2989459</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.8</AltitudeMeters>
|
||||
<DistanceMeters>432.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:33:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8611740</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2995813</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.0</AltitudeMeters>
|
||||
<DistanceMeters>485.998</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:33:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8614092</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3002272</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.2</AltitudeMeters>
|
||||
<DistanceMeters>540.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:33:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8616342</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3008814</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.4</AltitudeMeters>
|
||||
<DistanceMeters>594.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:34:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8618509</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3015420</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.5</AltitudeMeters>
|
||||
<DistanceMeters>648.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:34:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8620615</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3022071</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.7</AltitudeMeters>
|
||||
<DistanceMeters>702.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:34:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8622683</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3028751</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.8</AltitudeMeters>
|
||||
<DistanceMeters>756.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:35:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8624735</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3035441</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.9</AltitudeMeters>
|
||||
<DistanceMeters>810.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:35:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8626796</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3042125</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.9</AltitudeMeters>
|
||||
<DistanceMeters>864.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:35:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8628890</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3048786</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>38.0</AltitudeMeters>
|
||||
<DistanceMeters>918.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:36:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8631038</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3055406</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>38.0</AltitudeMeters>
|
||||
<DistanceMeters>972.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:36:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8633263</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3061968</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>38.0</AltitudeMeters>
|
||||
<DistanceMeters>1026.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:36:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8635585</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3068452</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>38.0</AltitudeMeters>
|
||||
<DistanceMeters>1080.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:37:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8638022</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3074837</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.9</AltitudeMeters>
|
||||
<DistanceMeters>1134.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:37:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8640589</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3081103</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.8</AltitudeMeters>
|
||||
<DistanceMeters>1188.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:37:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8643301</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3087227</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.7</AltitudeMeters>
|
||||
<DistanceMeters>1242.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:38:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8646168</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3093186</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.6</AltitudeMeters>
|
||||
<DistanceMeters>1296.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:38:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8649196</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3098958</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.5</AltitudeMeters>
|
||||
<DistanceMeters>1350.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:38:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8652388</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3104521</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.3</AltitudeMeters>
|
||||
<DistanceMeters>1404.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:39:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8655746</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3109853</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.1</AltitudeMeters>
|
||||
<DistanceMeters>1457.999</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:39:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8659267</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3114938</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.9</AltitudeMeters>
|
||||
<DistanceMeters>1512.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:39:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8662946</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3119758</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.7</AltitudeMeters>
|
||||
<DistanceMeters>1566.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:40:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8666773</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3124302</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.5</AltitudeMeters>
|
||||
<DistanceMeters>1620.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:40:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8670740</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3128561</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.2</AltitudeMeters>
|
||||
<DistanceMeters>1674.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:40:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8674834</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3132533</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.0</AltitudeMeters>
|
||||
<DistanceMeters>1728.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:41:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8679042</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3136218</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.7</AltitudeMeters>
|
||||
<DistanceMeters>1782.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:41:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8683351</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3139623</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.4</AltitudeMeters>
|
||||
<DistanceMeters>1836.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:41:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8687747</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3142760</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.2</AltitudeMeters>
|
||||
<DistanceMeters>1890.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:42:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8692217</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3145646</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.9</AltitudeMeters>
|
||||
<DistanceMeters>1944.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:42:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8696748</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3148301</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.6</AltitudeMeters>
|
||||
<DistanceMeters>1998.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:42:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8701329</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3150752</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.3</AltitudeMeters>
|
||||
<DistanceMeters>2052.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:43:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8705949</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3153027</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.1</AltitudeMeters>
|
||||
<DistanceMeters>2106.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:43:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8710599</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3155157</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.8</AltitudeMeters>
|
||||
<DistanceMeters>2160.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:43:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8715270</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3157179</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.6</AltitudeMeters>
|
||||
<DistanceMeters>2214.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:44:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8719954</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3159128</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.3</AltitudeMeters>
|
||||
<DistanceMeters>2268.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:44:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8724644</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3161042</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.1</AltitudeMeters>
|
||||
<DistanceMeters>2322.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:44:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8729334</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3162960</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.9</AltitudeMeters>
|
||||
<DistanceMeters>2376.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:45:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8734016</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3164919</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.7</AltitudeMeters>
|
||||
<DistanceMeters>2430.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:45:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8738683</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3166958</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.6</AltitudeMeters>
|
||||
<DistanceMeters>2484.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:45:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8743329</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3169112</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.4</AltitudeMeters>
|
||||
<DistanceMeters>2538.012</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:46:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8747943</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3171416</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.3</AltitudeMeters>
|
||||
<DistanceMeters>2592.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:46:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8752516</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3173901</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.2</AltitudeMeters>
|
||||
<DistanceMeters>2646.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:46:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8757037</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3176595</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.1</AltitudeMeters>
|
||||
<DistanceMeters>2700.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:47:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8761495</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3179525</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.0</AltitudeMeters>
|
||||
<DistanceMeters>2754.011</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:47:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8765876</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3182709</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.0</AltitudeMeters>
|
||||
<DistanceMeters>2808.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:47:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8770168</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3186164</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.0</AltitudeMeters>
|
||||
<DistanceMeters>2862.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:48:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8774357</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3189900</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.0</AltitudeMeters>
|
||||
<DistanceMeters>2916.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:48:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8778429</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3193923</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.1</AltitudeMeters>
|
||||
<DistanceMeters>2970.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:48:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8782372</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3198234</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.1</AltitudeMeters>
|
||||
<DistanceMeters>3024.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:49:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8786174</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3202829</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.2</AltitudeMeters>
|
||||
<DistanceMeters>3078.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:49:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8789825</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3207698</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.3</AltitudeMeters>
|
||||
<DistanceMeters>3132.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:49:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8793318</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3212829</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.5</AltitudeMeters>
|
||||
<DistanceMeters>3186.012</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:50:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8796647</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3218205</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.6</AltitudeMeters>
|
||||
<DistanceMeters>3240.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:50:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8799810</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3223808</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.8</AltitudeMeters>
|
||||
<DistanceMeters>3294.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:50:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8802809</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3229617</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.0</AltitudeMeters>
|
||||
<DistanceMeters>3348.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:51:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8805647</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3235609</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.2</AltitudeMeters>
|
||||
<DistanceMeters>3402.011</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:51:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8808333</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3241761</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.4</AltitudeMeters>
|
||||
<DistanceMeters>3456.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:51:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8810877</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3248052</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.7</AltitudeMeters>
|
||||
<DistanceMeters>3510.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:52:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8813292</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3254458</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.9</AltitudeMeters>
|
||||
<DistanceMeters>3564.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:52:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8815595</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3260960</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.2</AltitudeMeters>
|
||||
<DistanceMeters>3618.011</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:52:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8817805</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3267536</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.4</AltitudeMeters>
|
||||
<DistanceMeters>3672.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:53:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8819942</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3274167</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.7</AltitudeMeters>
|
||||
<DistanceMeters>3726.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:53:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8822028</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3280836</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.0</AltitudeMeters>
|
||||
<DistanceMeters>3780.011</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:53:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8824086</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3287525</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.2</AltitudeMeters>
|
||||
<DistanceMeters>3834.012</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:54:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8826139</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3294217</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.5</AltitudeMeters>
|
||||
<DistanceMeters>3888.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:54:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8828212</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3300896</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.8</AltitudeMeters>
|
||||
<DistanceMeters>3942.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:54:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8830327</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3307543</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.0</AltitudeMeters>
|
||||
<DistanceMeters>3996.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:55:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8832508</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3314142</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.3</AltitudeMeters>
|
||||
<DistanceMeters>4050.012</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:55:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8834774</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3320674</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.5</AltitudeMeters>
|
||||
<DistanceMeters>4104.014</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:55:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8837145</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3327118</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.8</AltitudeMeters>
|
||||
<DistanceMeters>4158.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:56:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8839640</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3333455</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.0</AltitudeMeters>
|
||||
<DistanceMeters>4212.014</DistanceMeters>
|
||||
</Trackpoint>
|
||||
</Track>
|
||||
</Lap>
|
||||
<Creator xsi:type="Device_t">
|
||||
<Name>kuhy workout_app</Name>
|
||||
<UnitId>0</UnitId>
|
||||
<ProductID>0</ProductID>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Creator>
|
||||
</Activity>
|
||||
</Activities>
|
||||
<Author xsi:type="Application_t">
|
||||
<Name>kuhy workout_app</Name>
|
||||
<Build>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Build>
|
||||
<LangID>en</LangID>
|
||||
<PartNumber>000-00000-00</PartNumber>
|
||||
</Author>
|
||||
</TrainingCenterDatabase>
|
||||
811
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-18-00-00_Running.tcx
vendored
Normal file
811
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-18-00-00_Running.tcx
vendored
Normal file
@ -0,0 +1,811 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd">
|
||||
<Activities>
|
||||
<Activity Sport="Running">
|
||||
<Id>2026-11-09T17:00:00Z</Id>
|
||||
<Lap StartTime="2026-11-09T17:00:00Z">
|
||||
<TotalTimeSeconds>1800</TotalTimeSeconds>
|
||||
<DistanceMeters>5400.009</DistanceMeters>
|
||||
<Calories>0</Calories>
|
||||
<Intensity>Active</Intensity>
|
||||
<TriggerMethod>Manual</TriggerMethod>
|
||||
<Track>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:00:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8584000</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>0.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:00:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8589396</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>60.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:00:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8594792</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>120.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:01:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8600188</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>180.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:01:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8605584</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>240.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:01:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8610980</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>300.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:02:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8616376</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>360.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:02:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8621772</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>420.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:02:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8627167</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>480.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:03:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8632563</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>540.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:03:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8637959</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>600.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:03:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8643355</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>660.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:04:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8648751</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>720.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:04:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8654147</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>780.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:04:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8659543</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>840.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:05:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8664939</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>900.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:05:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8670335</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>960.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:05:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8675731</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1020.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:06:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8681127</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1080.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:06:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8686523</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1140.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:06:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8691919</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1200.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:07:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8697315</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1260.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:07:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8702710</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1320.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:07:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8708106</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1380.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:08:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8713502</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1440.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:08:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8718898</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1500.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:08:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8724294</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1560.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:09:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8729690</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1620.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:09:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8735086</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1680.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:09:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8740482</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1740.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:10:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8745878</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1800.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:12:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8778254</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2160.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:12:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8783649</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2220.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:12:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8789045</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2280.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:13:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8794441</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2340.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:13:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8799837</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2400.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:13:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8805233</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2460.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:14:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8810629</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2520.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:14:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8816025</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2580.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:14:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8821421</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2640.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:15:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8826817</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2700.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:15:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8832213</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2760.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:15:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8837609</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2820.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:16:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8843005</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2880.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:16:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8848401</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2940.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:16:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8853797</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3000.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:17:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8859192</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3060.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:17:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8864588</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3120.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:17:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8869984</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3180.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:18:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8875380</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3240.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:18:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8880776</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3300.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:18:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8886172</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3360.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:19:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8891568</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3420.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:19:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8896964</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3480.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:19:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8902360</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3540.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:20:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8907756</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3600.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:20:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8913152</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3660.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:20:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8918548</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3720.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:21:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8923944</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3780.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:21:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8929340</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3840.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:21:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8934736</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3900.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:22:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8940131</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3960.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:22:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8945527</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4020.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:22:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8950923</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4080.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:23:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8956319</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4140.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:23:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8961715</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4200.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:23:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8967111</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4260.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:24:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8972507</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4320.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:24:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8977903</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4380.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:24:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8983299</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4440.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:25:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8988695</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4500.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:25:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8994091</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4560.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:25:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8999487</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4620.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:26:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9004883</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4680.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:26:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9010279</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4740.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:26:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9015674</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4800.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:27:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9021070</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4860.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:27:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9026466</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4920.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:27:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9031862</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4980.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:28:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9037258</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5040.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:28:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9042654</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5100.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:28:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9048050</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5160.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:29:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9053446</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5220.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:29:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9058842</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5280.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:29:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9064238</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5340.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:30:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9069634</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5400.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
</Track>
|
||||
</Lap>
|
||||
<Creator xsi:type="Device_t">
|
||||
<Name>kuhy workout_app</Name>
|
||||
<UnitId>0</UnitId>
|
||||
<ProductID>0</ProductID>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Creator>
|
||||
</Activity>
|
||||
</Activities>
|
||||
<Author xsi:type="Application_t">
|
||||
<Name>kuhy workout_app</Name>
|
||||
<Build>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Build>
|
||||
<LangID>en</LangID>
|
||||
<PartNumber>000-00000-00</PartNumber>
|
||||
</Author>
|
||||
</TrainingCenterDatabase>
|
||||
1391
screen_locker/tests/fixtures/run_tcx/golden_tracks.json
vendored
Normal file
1391
screen_locker/tests/fixtures/run_tcx/golden_tracks.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,4 @@
|
||||
"""Tests for ADB commands, phone connection, and database operations."""
|
||||
# pylint: disable=protected-access,unused-argument
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for ADB commands, phone connection, and database operations."""
|
||||
# pylint: disable=protected-access,unused-argument
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
193
screen_locker/tests/test_carrot_watch.py
Normal file
193
screen_locker/tests/test_carrot_watch.py
Normal file
@ -0,0 +1,193 @@
|
||||
"""An armed lock takes itself down when wake-alarm's signed carrot lands late.
|
||||
|
||||
2026-11-07: the lock armed at 07:49 because today's morning_session.json had
|
||||
not landed; it landed at 07:50 and nothing re-read it, so the morning session
|
||||
ran under a fullscreen lock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from gatelock.log_integrity import compute_entry_hmac
|
||||
import pytest
|
||||
|
||||
from screen_locker import _carrot_watch, _morning_session
|
||||
from screen_locker._carrot_watch import POLL_MS, watch_for_carrot
|
||||
from screen_locker._morning_session import MorningSkip
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
_EIGHT = datetime(2026, 11, 7, 8, 0).astimezone()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _signing_key(tmp_path: Path) -> Iterator[None]:
|
||||
key = tmp_path / "hmac.key"
|
||||
key.write_bytes(b"1" * 32)
|
||||
with patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key):
|
||||
yield
|
||||
|
||||
|
||||
def _sign(date: str, until: datetime) -> None:
|
||||
"""Write a signed session file into the conftest-redirected path."""
|
||||
entry: dict[str, object] = {
|
||||
"date": date,
|
||||
"outcome": "in_progress",
|
||||
"exempt_until": until.isoformat(),
|
||||
}
|
||||
entry["hmac"] = compute_entry_hmac(entry)
|
||||
_morning_session.MORNING_SESSION_FILE.write_text(json.dumps(entry))
|
||||
|
||||
|
||||
def _tick(now: datetime) -> tuple[MagicMock, MagicMock]:
|
||||
"""Arm the watch, run its first poll at ``now``; return (root, on_carrot)."""
|
||||
root, on_carrot = MagicMock(), MagicMock()
|
||||
watch_for_carrot(root, on_carrot)
|
||||
with patch.object(_carrot_watch, "_local_now", return_value=now):
|
||||
root.after.call_args.args[1]()
|
||||
return root, on_carrot
|
||||
|
||||
|
||||
class TestWatchForCarrot:
|
||||
"""The poll on the Tk loop."""
|
||||
|
||||
def test_local_now_is_timezone_aware_local_time(self) -> None:
|
||||
"""The poll's clock is the real, aware local time."""
|
||||
assert abs(_carrot_watch._local_now() - datetime.now().astimezone()) < (
|
||||
timedelta(seconds=5)
|
||||
)
|
||||
|
||||
def test_arms_a_fifteen_second_poll(self) -> None:
|
||||
"""Arming schedules the first check 15 s out and releases nothing."""
|
||||
root, on_carrot = MagicMock(), MagicMock()
|
||||
watch_for_carrot(root, on_carrot)
|
||||
assert POLL_MS == 15_000
|
||||
assert root.after.call_args.args[0] == POLL_MS
|
||||
on_carrot.assert_not_called()
|
||||
|
||||
def test_signed_todays_skip_releases_once_and_stops_polling(self) -> None:
|
||||
"""A skip that landed after arming is handed over; no re-arm."""
|
||||
until = _EIGHT + timedelta(hours=1, minutes=13)
|
||||
_sign("2026-11-07", until)
|
||||
root, on_carrot = _tick(_EIGHT)
|
||||
on_carrot.assert_called_once_with(MorningSkip("in_progress", until))
|
||||
assert root.after.call_count == 1
|
||||
|
||||
def test_no_file_keeps_the_lock_and_reschedules(self) -> None:
|
||||
"""No file: the lock stays and the next poll is scheduled."""
|
||||
root, on_carrot = _tick(_EIGHT)
|
||||
on_carrot.assert_not_called()
|
||||
assert root.after.call_count == 2
|
||||
assert root.after.call_args.args[0] == POLL_MS
|
||||
|
||||
def test_expired_skip_keeps_the_lock(self) -> None:
|
||||
"""A carrot whose exempt_until has passed is no skip."""
|
||||
_sign("2026-11-07", _EIGHT - timedelta(minutes=1))
|
||||
root, on_carrot = _tick(_EIGHT)
|
||||
on_carrot.assert_not_called()
|
||||
assert root.after.call_count == 2
|
||||
|
||||
def test_yesterdays_file_keeps_the_lock(self) -> None:
|
||||
"""A file dated another day is no skip."""
|
||||
_sign("2026-11-06", _EIGHT + timedelta(hours=3))
|
||||
_root, on_carrot = _tick(_EIGHT)
|
||||
on_carrot.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(("hour", "minute"), [(4, 59), (11, 0), (23, 30)])
|
||||
def test_outside_05_to_11_never_reads_or_releases(
|
||||
self, hour: int, minute: int
|
||||
) -> None:
|
||||
"""Outside 05:00-11:00 local the poll neither reads nor releases."""
|
||||
now = _EIGHT.replace(hour=hour, minute=minute)
|
||||
_sign("2026-11-07", now + timedelta(hours=1))
|
||||
with patch.object(_carrot_watch._morning_session, "morning_skip_today") as read:
|
||||
root, on_carrot = _tick(now)
|
||||
read.assert_not_called()
|
||||
on_carrot.assert_not_called()
|
||||
assert root.after.call_count == 2
|
||||
|
||||
def test_inside_window_reads_without_waiting(self) -> None:
|
||||
"""The poll never waits: it must not block the Tk loop."""
|
||||
with patch.object(
|
||||
_carrot_watch._morning_session, "morning_skip_today", return_value=None
|
||||
) as read:
|
||||
_tick(_EIGHT)
|
||||
read.assert_called_once_with(_EIGHT, wait=False)
|
||||
|
||||
def test_reader_failure_is_logged_and_the_poll_survives(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A read error warns and re-arms; one bad read must not end the watch."""
|
||||
with (
|
||||
patch.object(
|
||||
_carrot_watch._morning_session,
|
||||
"morning_skip_today",
|
||||
side_effect=OSError("disk gone"),
|
||||
),
|
||||
caplog.at_level(logging.WARNING, logger="screen_locker._carrot_watch"),
|
||||
):
|
||||
root, on_carrot = _tick(_EIGHT)
|
||||
on_carrot.assert_not_called()
|
||||
assert root.after.call_count == 2
|
||||
assert "disk gone" in caplog.text
|
||||
|
||||
|
||||
class TestLockerStandsDown:
|
||||
"""The locker side: marker, decision, warning, then the real close."""
|
||||
|
||||
def test_construction_arms_the_watch(
|
||||
self, mock_tk: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A built locker watches on its own root and its own stand-down."""
|
||||
with patch("screen_locker.screen_lock.watch_for_carrot") as watch:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
watch.assert_called_once_with(locker.root, locker._stand_down_for_carrot)
|
||||
|
||||
def test_stand_down_banks_marker_records_decision_then_closes(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Marker first, decision lock=no, warning, and only then close()."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
until = _EIGHT + timedelta(hours=1)
|
||||
order = MagicMock()
|
||||
with (
|
||||
patch.object(locker, "_save_early_bird_pending", order.save),
|
||||
patch("screen_locker._auto_upgrade.record_decision", order.record),
|
||||
patch.object(locker, "close", order.close),
|
||||
caplog.at_level(logging.WARNING, logger="screen_locker._auto_upgrade"),
|
||||
):
|
||||
locker._stand_down_for_carrot(MorningSkip("in_progress", until))
|
||||
assert [c[0] for c in order.mock_calls] == ["save", "record", "close"]
|
||||
decision = order.record.call_args.args[0]
|
||||
assert decision.locked is False
|
||||
assert decision.reason == "wake_alarm_skip_late"
|
||||
assert decision.extra["exempt_until"] == until.isoformat()
|
||||
assert decision.extra["outcome"] == "in_progress"
|
||||
assert "carrot" in caplog.text.lower()
|
||||
|
||||
def test_stand_down_goes_through_the_real_close_and_exits_zero(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""The real ``close``: lock window released, instance lock dropped, exit 0."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
with (
|
||||
patch.object(locker, "_save_early_bird_pending"),
|
||||
patch("screen_locker._auto_upgrade.record_decision"),
|
||||
patch.object(locker, "_lock") as lock,
|
||||
patch.object(locker._instance_lock, "release") as release,
|
||||
):
|
||||
locker._stand_down_for_carrot(MorningSkip("completed", _EIGHT))
|
||||
lock.close.assert_called_once_with()
|
||||
release.assert_called_once_with()
|
||||
mock_sys_exit.assert_called_once_with(0)
|
||||
@ -4,7 +4,6 @@ Before this, both fell through to building the demo lock screen. Split from
|
||||
``test_cli.py`` for the 250-line cap. ``sys.exit`` is a no-op suite-wide (see
|
||||
conftest), so each test installs one that really raises ``SystemExit``.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -47,7 +46,7 @@ def test_help_prints_usage_and_exits_zero(
|
||||
"""Help goes to stdout and the process exits 0 before any UI."""
|
||||
with pytest.raises(SystemExit) as exited:
|
||||
_main(["--production", flag])
|
||||
assert exited.value.code == 0
|
||||
assert not exited.value.code
|
||||
out = capsys.readouterr().out
|
||||
assert out.startswith("usage: python3 -m screen_locker.screen_lock")
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import freedays
|
||||
import sickdays
|
||||
|
||||
from screen_locker._compliance_state import explain_lock_decision
|
||||
from screen_locker._log_mixin import write_signed_entry
|
||||
from screen_locker.tests._sick_helpers import mark_sick
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -31,6 +32,7 @@ class TestExplainLockDecision:
|
||||
}
|
||||
|
||||
def test_scheduled_skip_short_circuits(self, tmp_path: Path) -> None:
|
||||
"""A freeday mark stops the chain at the scheduled_skip stage, first in the trace."""
|
||||
files = self._files(tmp_path)
|
||||
freedays.mark(
|
||||
freedays.today(),
|
||||
@ -46,6 +48,7 @@ class TestExplainLockDecision:
|
||||
assert result.trace[0].fired is True
|
||||
|
||||
def test_early_bird_window_still_open_skips(self, tmp_path: Path) -> None:
|
||||
"""An open early-bird window with a wake-alarm skip stops at early_bird_window_open."""
|
||||
files = self._files(tmp_path)
|
||||
files["early_bird_pending_file"].write_text(
|
||||
json.dumps({"date": _today(), "hmac": "sig"})
|
||||
@ -67,6 +70,7 @@ class TestExplainLockDecision:
|
||||
def test_expired_early_bird_falls_through_to_full_lock(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""An expired early-bird marker leads to would_lock via early_bird_expired."""
|
||||
files = self._files(tmp_path)
|
||||
files["early_bird_pending_file"].write_text(
|
||||
json.dumps({"date": _today(), "hmac": "sig"})
|
||||
@ -90,6 +94,7 @@ class TestExplainLockDecision:
|
||||
assert expired_step.fired is True
|
||||
|
||||
def test_sick_day_skips(self, tmp_path: Path) -> None:
|
||||
"""A sick day stops the chain at the sick_day stage."""
|
||||
files = self._files(tmp_path)
|
||||
mark_sick()
|
||||
result = explain_lock_decision(
|
||||
@ -104,6 +109,7 @@ class TestExplainLockDecision:
|
||||
def test_a_sick_day_that_keeps_the_workout_lock_does_not_skip(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A sick day that keeps the workout lock does not fire the sick_day skip."""
|
||||
files = self._files(tmp_path)
|
||||
mark_sick(keep=[sickdays.LOCKER_WORKOUT])
|
||||
result = explain_lock_decision(
|
||||
@ -117,6 +123,7 @@ class TestExplainLockDecision:
|
||||
assert sick_step.fired is False
|
||||
|
||||
def test_already_logged_skips(self, tmp_path: Path) -> None:
|
||||
"""A signed log entry for today stops the chain at already_logged."""
|
||||
files = self._files(tmp_path)
|
||||
files["log_file"].write_text(json.dumps({_today(): {"hmac": "sig"}}))
|
||||
with patch(
|
||||
@ -132,6 +139,7 @@ class TestExplainLockDecision:
|
||||
assert result.stage == "already_logged"
|
||||
|
||||
def test_wake_alarm_skip_skips(self, tmp_path: Path) -> None:
|
||||
"""A wake-alarm skip stops the chain at wake_alarm_skip."""
|
||||
files = self._files(tmp_path)
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
@ -157,6 +165,7 @@ class TestExplainLockDecision:
|
||||
assert "early-bird" not in result.reason
|
||||
|
||||
def test_relaxed_day_skips(self, tmp_path: Path) -> None:
|
||||
"""A relaxed day stops the chain at the relaxed_day stage."""
|
||||
files = self._files(tmp_path)
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
@ -176,7 +185,6 @@ class TestExplainLockDecision:
|
||||
even after the user already dismissed today's prompt, which is
|
||||
misleading — the popup is not about to be shown again.
|
||||
"""
|
||||
from screen_locker._log_mixin import write_signed_entry
|
||||
|
||||
files = self._files(tmp_path)
|
||||
write_signed_entry(files["log_file"], _today(), {"type": "relaxed_day_skip"})
|
||||
@ -190,6 +198,7 @@ class TestExplainLockDecision:
|
||||
assert result.stage == "relaxed_day_already_skipped"
|
||||
|
||||
def test_weekly_minimum_met_skips(self, tmp_path: Path) -> None:
|
||||
"""A met weekly minimum stops the chain at weekly_minimum_met."""
|
||||
files = self._files(tmp_path)
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
@ -201,6 +210,7 @@ class TestExplainLockDecision:
|
||||
assert result.stage == "weekly_minimum_met"
|
||||
|
||||
def test_full_lock_when_nothing_applies(self, tmp_path: Path) -> None:
|
||||
"""With no skip applicable the decision is would_lock with no auto-upgrade."""
|
||||
files = self._files(tmp_path)
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
|
||||
@ -14,9 +14,11 @@ class TestEarlyBirdWindowIsTheCarrot:
|
||||
"""The window is whatever wake-alarm's signed file says -- no wall clock."""
|
||||
|
||||
def test_no_clock_predicate_remains(self) -> None:
|
||||
"""The wall-clock early-bird window predicate no longer exists."""
|
||||
assert not hasattr(_compliance_state, "_early_bird_window_open")
|
||||
|
||||
def test_window_open_mirrors_wake_skip(self, tmp_path: Path) -> None:
|
||||
"""The early-bird decision follows wake_skip: true skips, false would lock."""
|
||||
files = {
|
||||
"log_file": tmp_path / "log.json",
|
||||
"early_bird_pending_file": tmp_path / "eb.json",
|
||||
|
||||
@ -50,7 +50,7 @@ class TestFindSiblingRefreshToken:
|
||||
expires_at="2026-08-24T12:00:00+00:00",
|
||||
)
|
||||
|
||||
assert find_sibling_refresh_tokens(tmp_path, skip="screen_locker") == []
|
||||
assert not find_sibling_refresh_tokens(tmp_path, skip="screen_locker")
|
||||
|
||||
def test_skips_unreadable_and_malformed_caches(self, tmp_path: Path) -> None:
|
||||
"""A corrupt sibling must not abort the search over the healthy ones."""
|
||||
@ -69,7 +69,7 @@ class TestFindSiblingRefreshToken:
|
||||
|
||||
def test_reports_nothing_when_no_sibling_has_one(self, tmp_path: Path) -> None:
|
||||
"""No source is a real outcome; the caller must hear it, not guess."""
|
||||
assert find_sibling_refresh_tokens(tmp_path, skip="screen_locker") == []
|
||||
assert not find_sibling_refresh_tokens(tmp_path, skip="screen_locker")
|
||||
|
||||
def test_missing_config_root_is_not_an_error(self, tmp_path: Path) -> None:
|
||||
"""A machine with no ~/.config yet has no donor -- and must not crash.
|
||||
@ -78,9 +78,7 @@ class TestFindSiblingRefreshToken:
|
||||
would take the screen for a reason that has nothing to do with
|
||||
working out.
|
||||
"""
|
||||
assert (
|
||||
find_sibling_refresh_tokens(tmp_path / "nope", skip="screen_locker") == []
|
||||
)
|
||||
assert not find_sibling_refresh_tokens(tmp_path / "nope", skip="screen_locker")
|
||||
|
||||
def test_cache_without_a_usable_token_is_skipped(self, tmp_path: Path) -> None:
|
||||
"""Readable JSON is not the same as a usable credential.
|
||||
|
||||
@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _sync_client
|
||||
from screen_locker._credential_recovery import recover_session
|
||||
from screen_locker.tests._credential_fixtures import write_cache as _write_cache
|
||||
|
||||
@ -94,7 +95,6 @@ class TestTryRecoverFirebaseSession:
|
||||
instead of clobbering the machine's live credential -- the exact
|
||||
accident this suite caused on 2026-08-27.
|
||||
"""
|
||||
from screen_locker import _sync_client
|
||||
|
||||
_write_cache(
|
||||
tmp_path / ".config" / "diet_guard" / "firebase_auth.json",
|
||||
|
||||
@ -4,7 +4,6 @@ The autouse ``_no_desktop_notifications`` fixture stubs
|
||||
``notify_workout_credit`` for the whole suite; the real one is captured here
|
||||
at import time, before any fixture runs.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -23,7 +22,7 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
_REAL_NOTIFY = _credit_notify.notify_workout_credit
|
||||
_real_notify = _credit_notify.notify_workout_credit
|
||||
_FRIDAY = "2026-10-09" # Thu-Sun: tonight is the second config field
|
||||
_MOD = "screen_locker._credit_notify"
|
||||
|
||||
@ -150,7 +149,7 @@ def test_notify_workout_credit_sends_the_composed_body() -> None:
|
||||
patch(f"{_MOD}.gaming_minutes_today", return_value=60),
|
||||
patch(f"{_MOD}.send_notification", return_value=True) as send,
|
||||
):
|
||||
assert _REAL_NOTIFY((0, 1200, 0), (0, 1260, 0), None)
|
||||
assert _real_notify((0, 1200, 0), (0, 1260, 0), None)
|
||||
send.assert_called_once_with(
|
||||
_credit_notify.TITLE, "Shutdown 21:00 (+1h) \N{MIDDLE DOT} gaming 1.0h"
|
||||
)
|
||||
|
||||
@ -4,7 +4,6 @@ The two StrongLifts ingestion paths of ONE session used to earn +2h and then
|
||||
+1h (2026-10-09). These tests drive the real shutdown arithmetic over a fake
|
||||
config so the minute figures, not just the branch taken, are pinned.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -154,7 +153,7 @@ class TestSlotCreditMinutes:
|
||||
with caplog.at_level(logging.WARNING, logger="screen_locker._workout_credit"):
|
||||
result, notify = _credit(locker, [_entry("pc_workout_verified")])
|
||||
assert result.already_counted_today is True
|
||||
assert result.extra_bonus_delta == 0
|
||||
assert not result.extra_bonus_delta
|
||||
assert locker._read_shutdown_config() == _START # untouched
|
||||
notify.assert_not_called()
|
||||
assert any(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user