screen-locker/screen_locker/tests/test_manual_workout_features.py
Krzysztof kuhy Rudnicki 87e625cc5f
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
Make the phone's manual-workout budget agree with the PC, twice over
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
2026-08-09 17:34:46 +02:00

376 lines
15 KiB
Python

"""Tests for manual-workout integration in _ui_flows.py and screen_lock.py."""
# pylint: disable=protected-access
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
class TestShowRetryAndManualWorkoutBudget:
"""Tests for the "Log Manual Workout" button in _show_retry_and_sick."""
def test_shows_button_when_budget_available(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(_sick_tracker, "load_history", return_value=SickHistory()),
patch.object(_manual_workout, "is_budget_exhausted", return_value=False),
):
locker._show_retry_and_sick("nope")
button_texts = {c.kwargs.get("text") for c in mock_tk.Button.call_args_list}
assert "Log Manual Workout" in button_texts
def test_hides_button_when_budget_exhausted(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(_sick_tracker, "load_history", return_value=SickHistory()),
patch.object(_manual_workout, "is_budget_exhausted", return_value=True),
):
locker._show_retry_and_sick("nope")
button_texts: set[str] = set()
for call in mock_tk.Button.call_args_list:
button_texts.add(call.kwargs.get("text", ""))
assert "Log Manual Workout" not in button_texts
text_calls = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list]
assert any("Manual-workout budget exhausted" in t for t in text_calls)
class TestOnManualWorkoutSaved:
"""Tests for UIFlowsMixin._on_manual_workout_saved."""
def test_sets_workout_data_and_schedules_unlock(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "unlock_screen", MagicMock())
object.__setattr__(locker.root, "after", MagicMock())
entry = {"type": "manual_workout", "source": "table tennis at Solec"}
locker._on_manual_workout_saved(entry)
assert locker.workout_data == entry
locker.unlock_screen.assert_not_called()
locker.root.after.assert_called_once_with(1500, locker.unlock_screen)
class TestOnManualWorkoutCancelled:
"""Tests for UIFlowsMixin._on_manual_workout_cancelled."""
def test_returns_to_phone_check(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_start_phone_check", MagicMock())
locker._on_manual_workout_cancelled()
locker._start_phone_check.assert_called_once()
class TestApplyWorkoutCredit:
"""Tests for ScreenLocker._apply_workout_credit under the multi-per-day rule.
Credit is now driven by the :class:`RecordResult` the write chokepoint
returns (``appended`` + ``prior_entries``), not a separate
``_was_already_counted_today`` probe (removed).
"""
def test_skips_credit_when_duplicate_already_logged_today(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""save_workout_log reports a duplicate (appended=False) → no new credit."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "manual_workout"}
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=False, prior_entries=[])),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=0)
)
result = locker._apply_workout_credit()
assert result.already_counted_today is True
assert result.shutdown_adjusted is False
assert result.new_debt is None
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._clear_debt_on_verified_workout.assert_not_called()
locker.save_workout_log.assert_called_once() # log entry write attempted
def test_first_workout_of_day_gets_base_shutdown_push(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""First counted workout of the day (empty prior_entries) → base +2h push."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "manual_workout"}
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=[])),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=2)
)
result = locker._apply_workout_credit()
assert result.already_counted_today is False
assert result.shutdown_adjusted is True
assert result.new_debt == 2
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_called_once()
def test_additional_manual_workout_earns_plus_one_hour(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A second manual workout on a day that already counted → +1h extra.
Manual workouts now stack intra-day shutdown credit exactly like
verified ones — the rate budget (not this stacking rule) is the sole
limiter on manual workouts. Mirrors
``test_additional_verified_workout_earns_plus_one_hour``.
"""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "manual_workout"}
prior = [{"workout_data": {"type": "manual_workout"}}]
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=[(21, 21, 5), (22, 22, 5)]),
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
with patch(
"screen_locker._workout_credit.count_weekly_workouts", return_value=5
):
result = locker._apply_workout_credit()
assert result.weekly_count == 5
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 1
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_called_once_with(1)
def test_additional_verified_workout_earns_plus_one_hour(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""An additional same-day VERIFIED workout earns the +1h extra bonus.
prior_entries already holds a counted workout, so this isn't the first
of the day; because the new type is verified it takes the +1h branch,
and extra_bonus_delta reflects the shutdown-config hour delta.
"""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=[(21, 21, 5), (22, 22, 5)]),
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
with patch(
"screen_locker._workout_credit.count_weekly_workouts", return_value=5
):
result = locker._apply_workout_credit()
assert result.weekly_count == 5
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 1
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_called_once_with(1)
def test_additional_workout_unreadable_shutdown_config_earns_no_bonus(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Additional same-day workout, but the shutdown config can't be read
(old_cfg is None) → the +1h branch is entered but bails out before
ever attempting the adjustment."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=None)
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
result = locker._apply_workout_credit()
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_not_called()
def test_additional_workout_of_uncounted_type_earns_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A workout whose type isn't in COUNTED_WORKOUT_TYPES (e.g.
``early_bird``) and isn't the first counted entry of the day →
neither the base push nor the +1h branch fires; the shutdown config
is never even consulted."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "early_bird"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(21, 21, 5))
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
result = locker._apply_credit_for_written_entry(prior)
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._read_shutdown_config.assert_not_called()
locker._adjust_shutdown_time_by.assert_not_called()
def test_skips_save_for_sick_day_type(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "sick_day"}
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
object.__setattr__(locker, "save_workout_log", MagicMock())
result = locker._apply_workout_credit()
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
)