mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Make the phone's manual-workout budget agree with the PC, twice over
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The phone and PC each compute a nominally SHARED manual-workout budget over
the same merged records, and disagreed on both halves of it.
The cap: _constants.py has said 10 per 30 days since 298daf0; the Dart mirror
was still 5, so between 5 and 9 the phone hard-blocked the form for a workout
the PC would have accepted and credited. The stricter side wins, so this cost
logged workouts rather than granting extra ones.
The counting rule: count_in_window counts ENTRIES ("each logged manual workout
consumes its own budget slot"), while countManualBudget built a Set of date
strings and counted DAYS -- despite a docstring claiming it mirrored the PC.
Three workouts on one day read 3/2 on the PC, over its 7-day cap, but 1/2 on
the phone, which kept accepting. Both now report 3 for that input. Per-entry is
the correct semantic because each entry separately earns weekly-count and
shutdown credit, so each must separately cost budget.
The wire fixture could never have caught either: the caps and the counting rule
never travel on the wire. The comment claiming it was "the tripwire that
catches drift" was wrong and is corrected. The real guard,
TestCrossLanguageBudgetConstants, parses manual_workout.dart and compares it to
the imported Python constants -- one test that fails on EITHER side's drift.
Asserting a Python constant against a Python literal would have stayed green
through 298daf0, which is the drift that actually happened.
Also: the history volume chart summed targetWeight x targetSets x targetReps,
so a session where every set failed plotted identically to one completed in
full -- the line only moved when progression changed the target. It now sums
weight x doneReps over the logged sets. On today's real session that is 2790 ->
1440 kg, because 5 sets of bench press were logged at 0 reps and were being
counted at full credit. No targetWeight fallback per set: weight is
non-nullable and always serialized, and substituting the target would overstate
a set the user never did.
design.md/dfesign_v2.md: record that the Crucial (progress lost on exit) and
High (sound + vibration) items are done, that breaks are still per-set, and
that the warmup fraction shipped as 4/5 -- neither the 2/3 nor the 3/4 the
specs asked for, and 4/5 is what is wanted.
Verified on the phone (release build, signed with the shared key, installed
with adb install -r so the workout history survived); 907 Python and 313 Dart
tests pass with the coverage bar green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4y93ZSnk2w5LH3B12LHNc
This commit is contained in:
parent
ffc6c72df0
commit
87e625cc5f
@ -3,14 +3,31 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker import _manual_workout, _sick_tracker
|
||||
from screen_locker._constants import (
|
||||
MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
|
||||
MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
|
||||
)
|
||||
from screen_locker._log_mixin import RecordResult
|
||||
from screen_locker._sick_tracker import SickHistory
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
# The Dart mirror of the budget caps; read at test time so drift on
|
||||
# EITHER side fails, not just a local edit to the Python constants.
|
||||
DART_MANUAL_WORKOUT = (
|
||||
pathlib.Path(__file__).resolve().parents[2]
|
||||
/ "stronglift_replacement"
|
||||
/ "workout_app"
|
||||
/ "lib"
|
||||
/ "models"
|
||||
/ "manual_workout.dart"
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
@ -307,3 +324,52 @@ class TestApplyWorkoutCredit:
|
||||
assert result.already_counted_today is False
|
||||
assert result.shutdown_adjusted is False
|
||||
locker.save_workout_log.assert_not_called()
|
||||
|
||||
|
||||
class TestCrossLanguageBudgetConstants:
|
||||
"""Pins the budget caps against the Dart phone app's own source file.
|
||||
|
||||
The wire-format fixture in test_manual_workout.py pins payload key NAMES,
|
||||
but the caps never travel on the wire, so it cannot catch them drifting —
|
||||
and they did: 298daf0 raised Python 5 -> 10 while touching zero Dart files,
|
||||
leaving the phone at 5 for weeks. The stricter side silently blocks a
|
||||
workout the other accepts.
|
||||
|
||||
So this READS THE DART FILE rather than asserting a hardcoded literal on
|
||||
each side. A test that compares a Python constant to a Python literal is
|
||||
only a guard against a local edit; it stays green when the other language
|
||||
drifts, which is the failure that actually happened. One test, either
|
||||
direction.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _dart_const(name: str) -> int:
|
||||
"""Return the int assigned to `const int <name>` in the Dart model."""
|
||||
source = DART_MANUAL_WORKOUT.read_text(encoding="utf-8")
|
||||
match = re.search(rf"const int {name} = (\d+);", source)
|
||||
assert match is not None, (
|
||||
f"{name} not found in {DART_MANUAL_WORKOUT} — it was renamed or "
|
||||
"removed; update this guard so the caps stay pinned."
|
||||
)
|
||||
return int(match.group(1))
|
||||
|
||||
def test_dart_model_file_exists(self) -> None:
|
||||
"""A moved/renamed Dart model must fail loudly, not skip the guard."""
|
||||
assert DART_MANUAL_WORKOUT.is_file(), (
|
||||
f"{DART_MANUAL_WORKOUT} is missing; the budget caps are no longer "
|
||||
"pinned across languages."
|
||||
)
|
||||
|
||||
def test_per_7_days_matches_dart_mirror(self) -> None:
|
||||
"""The 7-day cap must equal kManualWorkoutBudgetPer7Days."""
|
||||
assert (
|
||||
self._dart_const("kManualWorkoutBudgetPer7Days")
|
||||
== MANUAL_WORKOUT_BUDGET_PER_7_DAYS
|
||||
)
|
||||
|
||||
def test_per_30_days_matches_dart_mirror(self) -> None:
|
||||
"""The 30-day cap must equal kManualWorkoutBudgetPer30Days."""
|
||||
assert (
|
||||
self._dart_const("kManualWorkoutBudgetPer30Days")
|
||||
== MANUAL_WORKOUT_BUDGET_PER_30_DAYS
|
||||
)
|
||||
|
||||
@ -24,6 +24,7 @@ automatically decreases weight (in decrements of 2.5kg) if user failed to do the
|
||||
automatically decreases weight if user had a break from using the app
|
||||
Tracks how much time workout took -> Fully automatically, user CANNOT set it manually
|
||||
Adds optional warmup exercises (With weight equal 2/3 of target weight (rounding DOWN to nearest increment of 2.5kg) and always having 5 reps exactly and exactly one set) before each exercise (after warmup 3 minutes break too)
|
||||
SUPERSEDED (2026-08-09): the warmup fraction shipped as 4/5, not 2/3 (nor the 3/4 in dfesign_v2.md). 4/5 is confirmed intended; see Exercise.warmupWeight in workout_app/lib/models/exercise.dart.
|
||||
adds breaks between exercises (3 minutes if exercise succeeded and 5 minutes if it failed)
|
||||
The user selects exercise as done by tapping on a circle with number of reps for this exercise, exercises are organized in rows where one row = one full set of one exercise, tapping on a circle again means that
|
||||
the user failed to do the exercise and it decreases the rep by "1" tappign again further reduces this count, if user holds finger over the specific circle they can reset the state of this circle (which should
|
||||
|
||||
@ -3,13 +3,17 @@ This is a continouation from design.md file with what is left to be done and wha
|
||||
Crucial (max 1 feature):
|
||||
If user starts workout and later either exit the app completely or clicks the arrow in upper left the workout gets reset completely, all progress is lost this is very bad
|
||||
once user starts workout only by tapping finish and confirming that they INDEED finished workout should end it OR if user clicks and confirms RESET button, NOTHING ELSE
|
||||
DONE (verified 2026-08-09): StorageService.saveActiveSession persists after every tap and resumes on the same set/rep; the back arrow is gone (automaticallyImplyLeading: false) and Finish/Reset both confirm. Mirrored to /sdcard so even an uninstall resumes.
|
||||
|
||||
High (max 2 features):
|
||||
adds breaks between REPS (3 minutes if REP succeeded (as in all reps were done) and 5 minutes if it failed) <-- currently app ads breaks between SETS which wrong
|
||||
STILL OPEN (2026-08-09): breaks are still per-SET, and suppressed on the last set of an exercise. Needs a decision — "break between reps" would mean pausing mid-set, which is unusual; confirm intent before building.
|
||||
After break time (after REPS) is over app should play a sound and vibrate the phone and generally point the user attention towards the app
|
||||
DONE (verified 2026-08-09): break end plays assets/sounds/break_end.mp3 and vibrates 800ms. Caveat: only while the app is foregrounded — Dart timers suspend in the background, which needs a native foreground service (deferred).
|
||||
|
||||
Mid (max 3 features):
|
||||
change warmup exercises weight from 2/3 to 3/4 of target weight
|
||||
DONE, but shipped as 4/5 rather than 3/4 — 4/5 confirmed intended (2026-08-09).
|
||||
Warmups should be a selectable circles in a separate screen, optional but still interactive and after doing them give the user a 3 minute break ALSO
|
||||
The app should change between workout A and B AUTOMATICALLY, no user interaction if last workout done was "A" the next one should be "B" and then "A" and so on...
|
||||
|
||||
|
||||
@ -12,15 +12,20 @@ import 'package:crdt_sync/crdt_sync.dart';
|
||||
|
||||
// ── Shared constants ─────────────────────────────────────────────────────────
|
||||
// Hand-maintained mirror of the Python source of truth in
|
||||
// `screen_locker/_constants.py` (the `MANUAL_WORKOUT_*` values); the two
|
||||
// languages can't share a file, so the cross-language payload fixture (see the
|
||||
// model test and its Python twin) is the tripwire that catches drift.
|
||||
// `screen_locker/_constants.py` (the `MANUAL_WORKOUT_*` values).
|
||||
//
|
||||
// The payload fixture does NOT pin these: the caps never travel on the wire,
|
||||
// so it cannot see them drift — and they did (298daf0 raised Python 5 -> 10
|
||||
// touching no Dart, leaving the phone at 5). The actual tripwire is
|
||||
// `TestCrossLanguageBudgetConstants` in
|
||||
// `screen_locker/tests/test_manual_workout_features.py`, which parses THIS
|
||||
// file and compares it to the Python constants, so either side drifting fails.
|
||||
|
||||
/// Max manual workouts allowed in any rolling 7-day window.
|
||||
const int kManualWorkoutBudgetPer7Days = 2;
|
||||
|
||||
/// Max manual workouts allowed in any rolling 30-day window.
|
||||
const int kManualWorkoutBudgetPer30Days = 5;
|
||||
const int kManualWorkoutBudgetPer30Days = 10;
|
||||
|
||||
/// Minimum session length, in minutes, for a manual workout to count.
|
||||
const int kManualWorkoutMinDurationMinutes = 20;
|
||||
@ -353,18 +358,24 @@ class ManualBudget {
|
||||
|
||||
/// Counts DAYS with a manual workout in the rolling 7-/30-day windows.
|
||||
///
|
||||
/// The budget is counted per DAY, not per workout: several self-reports on one
|
||||
/// day consume a single slot (and only one counts toward the weekly total
|
||||
/// anyway). Each payload's own `date` (YYYY-MM-DD) is the window key, so both
|
||||
/// devices compute the same shared budget over the merged record set — this
|
||||
/// mirrors the PC's `count_in_window` (screen_locker/_manual_workout.py).
|
||||
/// The budget is counted per ENTRY, not per day: each self-report consumes its
|
||||
/// own slot, so three workouts logged on one day cost three. That matches the
|
||||
/// PC's `count_in_window` (screen_locker/_manual_workout.py), which is the
|
||||
/// source of truth — each entry separately earns weekly-count and shutdown
|
||||
/// credit, so each must separately cost budget.
|
||||
///
|
||||
/// This counted DAYS until 2026-08-09 (a `Set` of date strings) while the PC
|
||||
/// counted entries, so three same-day workouts read as 3/2 on the PC — over
|
||||
/// its 7-day cap — but 1/2 here, and the phone kept accepting. Callers pass
|
||||
/// one payload per synced record (`manual:<date>T<HH:MM>`), so same-day
|
||||
/// workouts at different times arrive as distinct payloads.
|
||||
ManualBudget countManualBudget(
|
||||
Iterable<Map<String, dynamic>> payloads,
|
||||
DateTime now,
|
||||
) {
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final weekDays = <String>{};
|
||||
final monthDays = <String>{};
|
||||
var week = 0;
|
||||
var month = 0;
|
||||
for (final payload in payloads) {
|
||||
final dateStr = payload['date'];
|
||||
if (dateStr is! String) continue;
|
||||
@ -374,8 +385,8 @@ ManualBudget countManualBudget(
|
||||
.difference(DateTime(date.year, date.month, date.day))
|
||||
.inDays;
|
||||
if (days < 0) continue;
|
||||
if (days < 7) weekDays.add(dateStr);
|
||||
if (days < 30) monthDays.add(dateStr);
|
||||
if (days < 7) week++;
|
||||
if (days < 30) month++;
|
||||
}
|
||||
return ManualBudget(week: weekDays.length, month: monthDays.length);
|
||||
return ManualBudget(week: week, month: month);
|
||||
}
|
||||
|
||||
@ -142,7 +142,18 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// (date, total volume) per session – sum of targetWeight × targetSets.
|
||||
/// (date, total volume) per session – tonnage actually lifted, summed as
|
||||
/// `weight × doneReps` over every logged set.
|
||||
///
|
||||
/// This deliberately uses the per-set actuals rather than
|
||||
/// `targetWeight × targetSets × targetReps`: with targets, a session where
|
||||
/// every set failed plotted identically to one completed in full, so the
|
||||
/// line only moved when progression changed the target.
|
||||
///
|
||||
/// The target-based fallback is defensive only — `ExerciseResult.toJson`
|
||||
/// has emitted `sets` since the app's first commit, so no row written by
|
||||
/// this app lacks it. It exists for hand-edited or truncated backup JSON,
|
||||
/// not for a migration that ever happened.
|
||||
List<(DateTime, double)> _totalVolumePoints() {
|
||||
final points = <(DateTime, double)>[];
|
||||
for (final row in _rows.reversed) {
|
||||
@ -150,10 +161,23 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
double total = 0;
|
||||
for (final ex in (json['exercises'] as List? ?? const [])) {
|
||||
final m = ex as Map<String, dynamic>;
|
||||
final w = (m['targetWeight'] as num?)?.toDouble() ?? 0;
|
||||
final s = (m['targetSets'] as num?)?.toInt() ?? 0;
|
||||
final r = (m['targetReps'] as num?)?.toInt() ?? 0;
|
||||
total += w * s * r;
|
||||
final sets = m['sets'] as List?;
|
||||
if (sets == null || sets.isEmpty) {
|
||||
final w = (m['targetWeight'] as num?)?.toDouble() ?? 0;
|
||||
final s = (m['targetSets'] as num?)?.toInt() ?? 0;
|
||||
final r = (m['targetReps'] as num?)?.toInt() ?? 0;
|
||||
total += w * s * r;
|
||||
continue;
|
||||
}
|
||||
for (final set in sets) {
|
||||
final sm = set as Map<String, dynamic>;
|
||||
// `weight` is a non-nullable double in SetResult and always
|
||||
// serialized, so there is no targetWeight fallback here — an absent
|
||||
// value would mean hand-edited JSON, and silently substituting the
|
||||
// target would overstate a set the user never did.
|
||||
final w = (sm['weight'] as num?)?.toDouble() ?? 0;
|
||||
total += w * ((sm['doneReps'] as num?)?.toInt() ?? 0);
|
||||
}
|
||||
}
|
||||
final date = DateTime.tryParse(row['date'] as String);
|
||||
if (date != null) points.add((date, total));
|
||||
|
||||
@ -141,6 +141,19 @@ void main() {
|
||||
expect(record.id, 'manual:2026-07-13T18:00');
|
||||
expect(record.fields['payload']!.$1, buildSyncPayload(wireDraft, _wireDate));
|
||||
});
|
||||
|
||||
// NOTE: comparing a Dart constant to a Dart literal only catches a local
|
||||
// edit — it stays green when Python drifts, which is the failure that
|
||||
// actually happened (298daf0). The real cross-language guard is
|
||||
// `TestCrossLanguageBudgetConstants` in
|
||||
// `screen_locker/tests/test_manual_workout_features.py`: it parses this
|
||||
// package's manual_workout.dart and compares it to the Python constants,
|
||||
// so either side drifting fails. This test only pins the local values so
|
||||
// a stray edit here is caught by `flutter test` too.
|
||||
test('budget constants are the locally expected values', () {
|
||||
expect(kManualWorkoutBudgetPer7Days, 2);
|
||||
expect(kManualWorkoutBudgetPer30Days, 10);
|
||||
});
|
||||
});
|
||||
|
||||
group('validateManualWorkout', () {
|
||||
@ -268,20 +281,34 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('countManualBudget (per-day)', () {
|
||||
group('countManualBudget (per-entry)', () {
|
||||
Map<String, dynamic> payload(String date, String start) => {
|
||||
'date': date,
|
||||
'start_time': start,
|
||||
};
|
||||
final now = DateTime(2026, 7, 16);
|
||||
|
||||
test('multiple workouts on one day count as a single day', () {
|
||||
// Was "count as a single day" until 2026-08-09, which disagreed with the
|
||||
// PC's count_in_window ("Counted per entry, not per day"): three same-day
|
||||
// workouts read 3/2 there — over the 7-day cap — but 1/2 here, so the
|
||||
// phone kept accepting what the PC had already refused.
|
||||
test('multiple workouts on one day each consume a slot', () {
|
||||
final budget = countManualBudget([
|
||||
payload('2026-07-13', '14:00'),
|
||||
payload('2026-07-13', '23:07'),
|
||||
], now);
|
||||
expect(budget.week, 1);
|
||||
expect(budget.month, 1);
|
||||
expect(budget.week, 2);
|
||||
expect(budget.month, 2);
|
||||
});
|
||||
|
||||
test('three on one day exhaust the 7-day cap, matching the PC', () {
|
||||
final budget = countManualBudget([
|
||||
payload('2026-07-13', '09:00'),
|
||||
payload('2026-07-13', '14:00'),
|
||||
payload('2026-07-13', '19:00'),
|
||||
], now);
|
||||
expect(budget.week, 3);
|
||||
expect(budget.week >= kManualWorkoutBudgetPer7Days, isTrue);
|
||||
});
|
||||
|
||||
test('workouts on distinct days each count', () {
|
||||
@ -292,13 +319,13 @@ void main() {
|
||||
expect(budget.week, 2);
|
||||
});
|
||||
|
||||
test('same-day plus another day counts two days', () {
|
||||
test('same-day pair plus another day counts three entries', () {
|
||||
final budget = countManualBudget([
|
||||
payload('2026-07-13', '14:00'),
|
||||
payload('2026-07-13', '23:07'),
|
||||
payload('2026-07-15', '09:00'),
|
||||
], now);
|
||||
expect(budget.week, 2);
|
||||
expect(budget.week, 3);
|
||||
});
|
||||
|
||||
test('ignores dates outside the 30-day window and future dates', () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user