Give manual workouts full effect parity; raise minimum to 5, double budget

Synced manual workouts never earned shutdown/debt credit (only weekly
count), which silently missed pushing shutdown time for a same-day
workout logged on the phone. Manual workouts now go through the same
credit logic as any other workout type, count individually toward the
weekly total and rate budget (no more once-per-day collapse), and stack
same-day shutdown bonuses like verified workouts. Also bumps the weekly
minimum from 4 to 5 and doubles the monthly manual-workout budget to 10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-07-19 13:50:39 +02:00
parent 57c3f30e7b
commit 298daf028d
17 changed files with 461 additions and 126 deletions

View File

@ -35,7 +35,7 @@ COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15
# activities like table tennis recur more often than illness. Once either
# window is exhausted the "Log Manual Workout" option disappears entirely.
MANUAL_WORKOUT_BUDGET_PER_7_DAYS = 2
MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 5
MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 10
# Minimum start-to-end duration for a manual entry to be accepted.
MANUAL_WORKOUT_MIN_DURATION_MINUTES = 20
# Minimum chars for the "what was done" activity-details field.

View File

@ -4,7 +4,9 @@ A manual workout logged on another device (phone form, or the PC form pushed
from elsewhere) arrives via crdt-sync as a ``kind="manual_workout"`` payload
(see :func:`screen_locker._manual_workout.build_sync_payload`). This module
turns those synced payloads into ordinary signed ``manual_workout`` entries in
``workout_log.json`` so they count toward the weekly minimum.
``workout_log.json`` so they count toward the weekly minimum and — via the
optional ``on_ingested`` callback — earn the same shutdown/debt reward a
live-logged workout would.
Trust model: the PC never trusts the phone's derived fields. Every synced
manual is re-validated with the same :func:`validate_manual_workout` the local
@ -31,9 +33,11 @@ from screen_locker._manual_workout import (
)
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from collections.abc import Callable, Iterable, Mapping
from pathlib import Path
OnIngestedCallback = Callable[[dict, "list[dict]"], None]
_logger = logging.getLogger(__name__)
# Key under which the source sync record id is stored in the ingested
@ -119,6 +123,7 @@ def ingest_manual_records(
records: Iterable[tuple[str, Mapping[str, object]]],
*,
today: str | None = None,
on_ingested: OnIngestedCallback | None = None,
) -> list[str]:
"""Ingest synced manual workouts into ``workout_log.json``.
@ -126,8 +131,17 @@ def ingest_manual_records(
reconstruct + re-validate the draft on the PC, enforce the rate budget, then
HMAC-sign and APPEND it under its own ``date`` (a day may hold several
workouts). Idempotent: dedup by ``record_id`` here and by ``workout_id`` in
the write chokepoint, so re-syncing the same record is a no-op. A back-dated
ingest raises the weekly count but never pushes tonight's shutdown.
the write chokepoint, so re-syncing the same record is a no-op.
When ``on_ingested`` is given, it's called ``on_ingested(entry, prior_entries)``
right after each newly-appended record — ``prior_entries`` being that
entry's own date's entries written before it — so the caller (see
``screen_lock.ScreenLocker._ingest_synced_manual_workouts``) can apply the
identical live-workout shutdown/debt reward. This applies regardless of
whether ``date`` is today or back-dated: there's only one current shutdown
config, so a back-dated sync still pushes it exactly as a live workout
would have that day.
Returns the record ids actually ingested.
"""
ingested: list[str] = []
@ -153,6 +167,15 @@ def ingest_manual_records(
continue
entry = build_entry(draft)
entry[_SYNC_ID_FIELD] = record_id
write_signed_entry(log_file, date, entry)
result = write_signed_entry(log_file, date, entry)
if not result.appended:
_logger.warning(
"Manual record %s collided with an existing workout_id — "
"skipped without credit",
record_id,
)
continue
ingested.append(record_id)
if on_ingested is not None:
on_ingested(entry, result.prior_entries)
return ingested

View File

@ -72,11 +72,13 @@ def count_in_window(
*,
today: str | None = None,
) -> int:
"""Return how many DAYS in the trailing window hold a ``manual_workout``.
"""Return how many ``manual_workout`` ENTRIES fall in the trailing window.
The manual-workout budget is counted per DAY: several self-reports on one
day consume a single slot (and only one counts toward the weekly total
anyway), so a day contributes at most one here.
Counted per entry, not per day: each logged manual workout consumes its
own budget slot, matching how each one also earns its own weekly-count
and shutdown credit (see ``screen_locker._weekly_check.COUNTED_WORKOUT_TYPES``
and ``screen_locker._workout_credit``) — the budget is the sole limiter on
how many you can log, not a once-per-day collapse.
"""
today_str = today or _today_iso()
today_dt = _parse_iso(today_str)
@ -88,11 +90,11 @@ def count_in_window(
parsed = _parse_iso(date_str)
if parsed is None or not (cutoff < parsed <= today_dt):
continue
if any(
entry.get("workout_data", {}).get("type") == MANUAL_WORKOUT_TYPE
count += sum(
1
for entry in entries
):
count += 1
if entry.get("workout_data", {}).get("type") == MANUAL_WORKOUT_TYPE
)
return count

View File

@ -22,7 +22,7 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__)
WEEKLY_WORKOUT_MINIMUM: int = 4
WEEKLY_WORKOUT_MINIMUM: int = 5
# Python weekday(): Mon=0, Tue=1, Wed=2, Thu=3, Fri=4, Sat=5, Sun=6
_RELAXED_WEEKDAYS: frozenset[int] = frozenset({1, 2, 3}) # Tue, Wed, Thu
@ -33,8 +33,10 @@ _RELAXED_WEEKDAYS: frozenset[int] = frozenset({1, 2, 3}) # Tue, Wed, Thu
VERIFIED_WORKOUT_TYPES: frozenset[str] = frozenset(
{"phone_verified", "runnerup_verified"},
)
# The single self-reported type. It counts at most ONCE per day (see
# count_weekly_workouts) so it can't be used to inflate the weekly total.
# The single self-reported type. It counts toward the weekly total
# INDIVIDUALLY, same as a verified workout — the manual-workout rate budget
# (see screen_locker._manual_workout) is the anti-gaming limiter, not a
# once-per-day collapse here.
MANUAL_WORKOUT_TYPE: str = "manual_workout"
# Types that count toward the weekly minimum *and* are eligible for the base
# shutdown-time bonus (see screen_lock._try_adjust_shutdown_for_workout).
@ -61,21 +63,21 @@ def count_weekly_workouts(
*,
today: datetime | None = None,
) -> int:
"""Count counted workouts in the current ISO week under the anti-gaming rule.
"""Count counted workouts in the current ISO week.
Each VERIFIED workout (:data:`VERIFIED_WORKOUT_TYPES`) counts individually,
so multiple real workouts on one day all count. Self-reported
``manual_workout`` entries count at most ONCE per day, so they can't inflate
the total. Feeds both the weekly lock minimum (:func:`has_weekly_minimum`)
and the banked bonus.
Every entry whose type is in :data:`COUNTED_WORKOUT_TYPES` — verified or
manual — counts individually, so multiple workouts on one day all count.
Manual workouts are rate-limited separately by the manual-workout budget
(:mod:`screen_locker._manual_workout`), not by a once-per-day collapse
here. Feeds both the weekly lock minimum (:func:`has_weekly_minimum`) and
the banked bonus.
Args:
log_file: Path to ``workout_log.json``.
today: Override for the current local datetime (for testing).
Returns:
The weekly workout count (Mon-Sun, up to and including today) under the
verified-stack / manual-once-per-day rule.
The weekly workout count (Mon-Sun, up to and including today).
"""
dt = today if today is not None else datetime.now(tz=timezone.utc).astimezone()
week_start = (dt - timedelta(days=dt.weekday())).date()
@ -99,15 +101,10 @@ def count_weekly_workouts(
continue
if not (week_start <= entry_date <= today_date):
continue
has_manual = False
for entry in entries:
wtype = entry.get("workout_data", {}).get("type", "")
if wtype in VERIFIED_WORKOUT_TYPES:
count += 1 # each verified workout counts
elif wtype == MANUAL_WORKOUT_TYPE:
has_manual = True
if has_manual:
count += 1 # all of a day's manual entries count as one
if wtype in COUNTED_WORKOUT_TYPES:
count += 1 # each counted workout — verified or manual — counts
return count

View File

@ -14,11 +14,7 @@ import logging
from typing import TYPE_CHECKING
from screen_locker import _sick_tracker
from screen_locker._weekly_check import (
COUNTED_WORKOUT_TYPES,
VERIFIED_WORKOUT_TYPES,
count_weekly_workouts,
)
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES, count_weekly_workouts
if TYPE_CHECKING:
from pathlib import Path
@ -76,59 +72,40 @@ class WorkoutCreditMixin:
_sick_tracker.save_history(history)
return new_debt
def _apply_workout_credit(self) -> WorkoutCreditResult:
"""Append ``workout_data`` and apply its reward, scaled to the day.
def _apply_credit_for_written_entry(
self, prior_entries: list[dict]
) -> WorkoutCreditResult:
"""Apply the shutdown/debt reward for an already-written ``workout_data``.
Shared by the locked-screen flow (:meth:`ScreenLocker.unlock_screen`),
the voluntary ``StatusWindow`` "Log Manual Workout" path, and the
Check-Phone verify path. Rewards, from the day's entries *before* this
one (via the write chokepoint's :class:`RecordResult`):
Shared by :meth:`_apply_workout_credit` (the live locked-screen,
Status-Window, and Check-Phone paths, which always write under today)
and the manual-sync ingestion path
(:func:`screen_locker._manual_sync.ingest_manual_records`, via its
``on_ingested`` callback — which writes under the entry's own date,
today or back-dated). ``prior_entries`` must be that entry's day's
entries *before* it, so "first counted workout of the day" is
evaluated relative to the entry's own date; the shutdown adjustment
itself always lands on the current shutdown config, since there's
only ever one "tonight" to push regardless of which day earned it.
Rewards:
* duplicate (same ``workout_id`` already logged) → no new credit;
* first counted workout of the day → base **+2h** (cap 23:00);
* an additional same-day VERIFIED workout → **+1h** (cap midnight);
* an additional same-day manual workout → no shutdown credit
(self-reports can't stack intra-day credit — anti-gaming).
This method always files under *today*, so its credit is the intra-day
reward; back-dated writes (manual-sync, RunnerUp backfill) go straight
through the write chokepoint and earn only the weekly count, never a
push to tonight's shutdown.
* an additional same-day counted workout (verified OR manual) →
**+1h** (cap midnight) — manual workouts stack exactly like
verified ones; the manual-workout rate budget is the only limiter.
"""
# sick_day is already persisted to sick_history.json by
# _finalize_sick_day — workout_log.json is reserved for real outcomes.
if self.workout_data.get("type") == "sick_day":
return WorkoutCreditResult(
shutdown_adjusted=False,
new_debt=None,
extra_bonus_delta=0,
weekly_count=count_weekly_workouts(self.log_file),
already_counted_today=False,
)
result = self.save_workout_log()
weekly_count = count_weekly_workouts(self.log_file)
if not result.appended:
# A workout with this id was already recorded today — idempotent.
return WorkoutCreditResult(
shutdown_adjusted=False,
new_debt=None,
extra_bonus_delta=0,
weekly_count=weekly_count,
already_counted_today=True,
)
first_counted_today = not any(
entry.get("workout_data", {}).get("type") in COUNTED_WORKOUT_TYPES
for entry in result.prior_entries
for entry in prior_entries
)
shutdown_adjusted = False
extra_bonus_delta = 0
if first_counted_today:
shutdown_adjusted = self._try_adjust_shutdown_for_workout()
elif self.workout_data.get("type") in VERIFIED_WORKOUT_TYPES:
elif self.workout_data.get("type") in COUNTED_WORKOUT_TYPES:
old_cfg = self._read_shutdown_config()
if old_cfg and self._adjust_shutdown_time_by(1):
new_cfg = self._read_shutdown_config()
@ -144,3 +121,38 @@ class WorkoutCreditMixin:
weekly_count=weekly_count,
already_counted_today=False,
)
def _apply_workout_credit(self) -> WorkoutCreditResult:
"""Append ``workout_data`` and apply its reward, scaled to the day.
Shared by the locked-screen flow (:meth:`ScreenLocker.unlock_screen`),
the voluntary ``StatusWindow`` "Log Manual Workout" path, and the
Check-Phone verify path. Always files under *today*; see
:meth:`_apply_credit_for_written_entry` for the shared reward logic
(a duplicate — same ``workout_id`` already logged — earns no new
credit).
"""
# sick_day is already persisted to sick_history.json by
# _finalize_sick_day — workout_log.json is reserved for real outcomes.
if self.workout_data.get("type") == "sick_day":
return WorkoutCreditResult(
shutdown_adjusted=False,
new_debt=None,
extra_bonus_delta=0,
weekly_count=count_weekly_workouts(self.log_file),
already_counted_today=False,
)
result = self.save_workout_log()
if not result.appended:
# A workout with this id was already recorded today — idempotent.
return WorkoutCreditResult(
shutdown_adjusted=False,
new_debt=None,
extra_bonus_delta=0,
weekly_count=count_weekly_workouts(self.log_file),
already_counted_today=True,
)
return self._apply_credit_for_written_entry(result.prior_entries)

View File

@ -218,11 +218,41 @@ class ScreenLocker(
self._check_heat_skip_exit()
def _ingest_synced_manual_workouts(self) -> None:
"""Sync manual workouts: publish this PC's, ingest everyone else's."""
"""Sync manual workouts: publish this PC's, ingest everyone else's.
Each newly-ingested record earns the identical shutdown/debt reward a
live-logged workout would (see
``WorkoutCreditMixin._apply_credit_for_written_entry``), regardless of
whether it's dated today or back-dated — there's only one current
shutdown config, so a back-dated sync still pushes it.
"""
push_pc_workouts(self.log_file)
ingested = ingest_manual_records(self.log_file, pull_all_manual_records())
ingested = ingest_manual_records(
self.log_file,
pull_all_manual_records(),
on_ingested=self._credit_ingested_manual_workout,
)
for record_id in ingested:
_logger.info("Ingested synced manual workout: %s", record_id)
self.workout_data = {}
def _credit_ingested_manual_workout(
self, entry: dict[str, str], prior_entries: list[dict]
) -> None:
"""Apply the live-workout reward to a manual workout ingested via sync."""
self.workout_data = entry
credit = self._apply_credit_for_written_entry(prior_entries)
if credit.shutdown_adjusted:
_logger.info(
"Synced manual workout pushed shutdown time +2h: %s",
entry.get("source", ""),
)
elif credit.extra_bonus_delta:
_logger.info(
"Synced manual workout added +%dh shutdown time: %s",
credit.extra_bonus_delta,
entry.get("source", ""),
)
def _auto_fill_week_runnerup_bonus(self) -> None:
"""Auto-fill missed RunnerUp workouts and award any earned bonus."""

View File

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
from unittest.mock import patch
from screen_locker import _manual_sync
from screen_locker._log_mixin import RecordResult
from screen_locker._manual_sync import (
ingest_manual_records,
reconstruct_draft,
@ -172,6 +173,67 @@ class TestIngestManualRecords:
assert "hmac" not in stored[_DATE][0]
class TestIngestManualRecordsCallback:
"""Tests for the ``on_ingested`` callback that drives shutdown/debt credit."""
def test_on_ingested_called_with_entry_and_prior_entries(
self, tmp_path: Path
) -> None:
log_file = tmp_path / "workout_log.json"
log_file.write_text(
json.dumps({_DATE: [{"workout_data": {"type": "phone_verified"}}]})
)
record = _manual_record(_TT_DRAFT)
calls: list[tuple[dict, list[dict]]] = []
ingest_manual_records(
log_file,
[record],
today=_DATE,
on_ingested=lambda entry, prior: calls.append((entry, prior)),
)
assert len(calls) == 1
entry, prior = calls[0]
assert entry["type"] == "manual_workout"
assert entry["sync_record_id"] == record[0]
assert prior == [{"workout_data": {"type": "phone_verified"}}]
def test_on_ingested_not_called_when_budget_exhausted(self, tmp_path: Path) -> None:
log_file = tmp_path / "workout_log.json"
record = _manual_record(_TT_DRAFT)
calls: list[tuple[dict, list[dict]]] = []
with patch.object(_manual_sync, "is_budget_exhausted", return_value=True):
ingest_manual_records(
log_file,
[record],
today=_DATE,
on_ingested=lambda entry, prior: calls.append((entry, prior)),
)
assert calls == []
def test_write_chokepoint_collision_is_not_credited_or_returned(
self, tmp_path: Path
) -> None:
"""If the write chokepoint's own workout_id dedup no-ops the write
(``appended=False``), the record must not be reported as ingested or
handed to ``on_ingested`` — crediting a write that never happened
would be a real correctness bug, not just a missed optimization."""
log_file = tmp_path / "workout_log.json"
record = _manual_record(_TT_DRAFT)
calls: list[tuple[dict, list[dict]]] = []
with patch(
"screen_locker._manual_sync.write_signed_entry",
return_value=RecordResult(appended=False, prior_entries=[]),
):
ingested = ingest_manual_records(
log_file,
[record],
today=_DATE,
on_ingested=lambda entry, prior: calls.append((entry, prior)),
)
assert ingested == []
assert calls == []
class TestIngestHelpers:
def test_already_ingested_finds_id_in_per_day_lists(self) -> None:
"""_already_ingested takes normalized ``{date: [entry, ...]}`` and

View File

@ -4,6 +4,7 @@
from __future__ import annotations
import dataclasses
from datetime import datetime, timedelta, timezone
import json
from typing import TYPE_CHECKING
@ -102,6 +103,23 @@ class TestCountInWindow:
log_file = tmp_path / "workout_log.json"
assert count_in_window(log_file, 7) == 0
def test_multiple_same_day_entries_each_count(self, tmp_path: Path) -> None:
"""Per-entry counting: same-day manual workouts no longer collapse to
one slot — each consumes its own, matching the new weekly-count
parity with verified workouts."""
log_file = tmp_path / "workout_log.json"
log_file.write_text(
json.dumps(
{
"2026-07-04": [
{"workout_data": {"type": "manual_workout"}},
{"workout_data": {"type": "manual_workout"}},
],
}
)
)
assert count_in_window(log_file, 7, today=_TODAY) == 2
class TestIsBudgetExhausted:
"""Tests for is_budget_exhausted."""
@ -121,12 +139,15 @@ class TestIsBudgetExhausted:
def test_true_when_monthly_exhausted(self, tmp_path: Path) -> None:
log_file = tmp_path / "workout_log.json"
# All strictly after the 30d cutoff (2026-06-05) but at/before the
# 7d cutoff (2026-06-28), so only the 30d window sees them.
dates = ["2026-06-08", "2026-06-13", "2026-06-18", "2026-06-23", "2026-06-28"]
entries = dict.fromkeys(
dates[:MANUAL_WORKOUT_BUDGET_PER_30_DAYS], "manual_workout"
)
# MANUAL_WORKOUT_BUDGET_PER_30_DAYS distinct dates, all strictly after
# the 30d cutoff (2026-06-05) but at/before the 7d cutoff (2026-06-28),
# so only the 30d window sees them.
today_dt = datetime.strptime(_TODAY, "%Y-%m-%d").replace(tzinfo=timezone.utc)
dates = [
(today_dt - timedelta(days=8 + i)).strftime("%Y-%m-%d")
for i in range(MANUAL_WORKOUT_BUDGET_PER_30_DAYS)
]
entries = dict.fromkeys(dates, "manual_workout")
_write_logs(log_file, entries)
assert count_in_window(log_file, 7, today=_TODAY) == 0
assert is_budget_exhausted(log_file, today=_TODAY) is True

View File

@ -139,14 +139,15 @@ class TestApplyWorkoutCredit:
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_called_once()
def test_additional_manual_workout_earns_no_shutdown_credit(
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 → no shutdown push.
"""A second manual workout on a day that already counted → +1h extra.
Anti-gaming: self-reports can't stack intra-day shutdown credit. The
prior day already holds a counted entry, and the new workout is manual
(not verified), so neither the base push nor the +1h extra applies.
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"}
@ -159,19 +160,27 @@ class TestApplyWorkoutCredit:
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()
result = locker._apply_workout_credit()
assert result.weekly_count == 5
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 0
assert result.extra_bonus_delta == 1
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.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
@ -215,6 +224,71 @@ class TestApplyWorkoutCredit:
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:

View File

@ -43,7 +43,7 @@ class TestCheckNonVerifyExitsExtras:
with (
patch("screen_locker.screen_lock.reset_to_base_if_new_day"),
patch(
"screen_locker.screen_lock.count_weekly_workouts", side_effect=[0, 5]
"screen_locker.screen_lock.count_weekly_workouts", side_effect=[0, 6]
),
patch(
"screen_locker.screen_lock.process_week_transition",
@ -54,7 +54,7 @@ class TestCheckNonVerifyExitsExtras:
patch("screen_locker.screen_lock.sys.exit"),
):
locker._check_non_verify_exits()
locker._adjust_shutdown_time_by.assert_called_once_with(1) # bonus = 5-max(4,0)
locker._adjust_shutdown_time_by.assert_called_once_with(1) # bonus = 6-max(5,0)
def test_auto_fill_no_bonus_when_min_not_exceeded(
self,
@ -76,7 +76,7 @@ class TestCheckNonVerifyExitsExtras:
)
with (
patch("screen_locker.screen_lock.reset_to_base_if_new_day"),
# prev=2, new=3 → bonus=max(0,3-max(4,2))=0 → no bonus logger call
# prev=2, new=3 → bonus=max(0,3-max(5,2))=0 → no bonus logger call
patch(
"screen_locker.screen_lock.count_weekly_workouts", side_effect=[2, 3]
),

View File

@ -298,3 +298,83 @@ class TestIngestSyncedManualWorkouts:
),
):
locker._ingest_synced_manual_workouts()
def test_passes_credit_callback_and_resets_workout_data(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""The callback wired into ingest_manual_records must be
``locker._credit_ingested_manual_workout``, so a synced manual
workout earns the same reward a live one would; workout_data is reset
afterward so it can't leak into a later interactive flow."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"stale": "state"}
with (
patch(
"screen_locker.screen_lock.pull_all_manual_records",
return_value=[],
),
patch(
"screen_locker.screen_lock.ingest_manual_records",
return_value=[],
) as ingest,
):
locker._ingest_synced_manual_workouts()
assert ingest.call_args.kwargs["on_ingested"] == (
locker._credit_ingested_manual_workout
)
assert locker.workout_data == {}
def test_credit_ingested_manual_workout_applies_reward(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""_credit_ingested_manual_workout sets workout_data to the ingested
entry and applies the shared credit logic — proving a synced manual
workout (today's or back-dated) earns full shutdown/debt credit,
exactly like a live-logged one."""
locker = create_locker(mock_tk, tmp_path)
entry = {"type": "manual_workout", "source": "table tennis at Solec"}
prior: list[dict] = []
with patch.object(
locker,
"_apply_credit_for_written_entry",
return_value=MagicMock(shutdown_adjusted=True, extra_bonus_delta=0),
) as apply_credit:
locker._credit_ingested_manual_workout(entry, prior)
assert locker.workout_data == entry
apply_credit.assert_called_once_with(prior)
def test_credit_ingested_manual_workout_logs_extra_bonus(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A second same-day synced manual workout (extra_bonus_delta, not the
base push) takes the elif branch — a back-dated sync can still stack
the +1h bonus exactly like an additional same-day verified workout."""
locker = create_locker(mock_tk, tmp_path)
entry = {"type": "manual_workout", "source": "gym"}
prior = [{"workout_data": {"type": "manual_workout"}}]
with patch.object(
locker,
"_apply_credit_for_written_entry",
return_value=MagicMock(shutdown_adjusted=False, extra_bonus_delta=1),
) as apply_credit:
locker._credit_ingested_manual_workout(entry, prior)
assert locker.workout_data == entry
apply_credit.assert_called_once_with(prior)
def test_credit_ingested_manual_workout_no_reward_logs_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A duplicate/no-op credit result (neither shutdown_adjusted nor
extra_bonus_delta) → neither log line fires; credit is still applied
via workout_data being set."""
locker = create_locker(mock_tk, tmp_path)
entry = {"type": "manual_workout", "source": "gym"}
prior: list[dict] = []
with patch.object(
locker,
"_apply_credit_for_written_entry",
return_value=MagicMock(shutdown_adjusted=False, extra_bonus_delta=0),
) as apply_credit:
locker._credit_ingested_manual_workout(entry, prior)
assert locker.workout_data == entry
apply_credit.assert_called_once_with(prior)

View File

@ -250,12 +250,13 @@ class TestWeeklyTotalUsesPerWorkoutRule:
def test_multiple_same_day_workouts_all_count(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""One day with 1 manual + 3 verified entries = 4, not capped at 1.
"""One day with 2 manual + 3 verified entries = 5, not capped at 1.
Regression test: the summary used to derive its total from a
per-day checkmark count (max 1 per day), undercounting any day
holding more than one counted workout — exactly what multi-workout
days are supposed to support.
days are supposed to support. Also covers manual workouts counting
individually (no once-per-day collapse), same as verified ones.
"""
from datetime import datetime, timezone
@ -269,6 +270,7 @@ class TestWeeklyTotalUsesPerWorkoutRule:
{"workout_data": {"type": "runnerup_verified", "source": "a"}},
{"workout_data": {"type": "phone_verified", "source": "b"}},
{"workout_data": {"type": "runnerup_verified", "source": "c"}},
{"workout_data": {"type": "manual_workout", "source": "gym2"}},
]
}
)
@ -284,4 +286,4 @@ class TestWeeklyTotalUsesPerWorkoutRule:
):
run_status(locker)
out = capsys.readouterr().out
assert "Weekly minimum met exactly (4/4)." in out
assert "Weekly minimum met exactly (5/5)." in out

View File

@ -46,7 +46,7 @@ class TestGatherStatus:
assert snap.today.counted is False
assert snap.today.is_sick_day is False
assert snap.week.counted_count == 0
assert snap.week.remaining == 4
assert snap.week.remaining == 5
assert snap.week.extra == 0
assert snap.shutdown.tonight is None
assert snap.shutdown.rest_of_week[0].hour == 21
@ -161,7 +161,13 @@ class TestGatherStatus:
json.dumps(
{
d: {"workout_data": {"type": "phone_verified"}}
for d in ("2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04")
for d in (
"2024-01-01",
"2024-01-02",
"2024-01-03",
"2024-01-04",
"2024-01-05",
)
}
)
)
@ -170,24 +176,28 @@ class TestGatherStatus:
):
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
assert snap.week.counted_count == 4
assert snap.week.counted_count == 5
assert snap.week.remaining == 0
assert snap.week.extra == 0
assert snap.lock_explanation.stage == "weekly_minimum_met"
def test_extra_workouts_beyond_minimum(self, tmp_path: Path) -> None:
files = _files(tmp_path)
# Jan 1-4 one each, Jan 5 (today, Friday — the last day in-week without
# going into the future) holds two, for 6 counted total: 1 above the
# new minimum of 5. Also exercises multiple same-day entries each
# counting individually.
files["log_file"].write_text(
json.dumps(
{
d: {"workout_data": {"type": "phone_verified"}}
for d in (
"2024-01-01",
"2024-01-02",
"2024-01-03",
"2024-01-04",
"2024-01-05",
)
"2024-01-01": {"workout_data": {"type": "phone_verified"}},
"2024-01-02": {"workout_data": {"type": "phone_verified"}},
"2024-01-03": {"workout_data": {"type": "phone_verified"}},
"2024-01-04": {"workout_data": {"type": "phone_verified"}},
"2024-01-05": [
{"workout_data": {"type": "phone_verified"}},
{"workout_data": {"type": "phone_verified"}},
],
}
)
)
@ -326,7 +336,13 @@ class TestFormatSummaryLine:
json.dumps(
{
d: {"workout_data": {"type": "phone_verified"}}
for d in ("2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04")
for d in (
"2024-01-01",
"2024-01-02",
"2024-01-03",
"2024-01-04",
"2024-01-05",
)
}
)
)

View File

@ -26,7 +26,7 @@ class TestRunStatusFill:
"""n_filled > 0, bonus > 0, adjust succeeds → bonus line shown.
count_weekly_workouts is called twice: before the fill (3) and after
(5) — a flat return_value would collapse both calls to the same
(6) — a flat return_value would collapse both calls to the same
number and mask the bonus math, so this uses side_effect instead.
"""
eb_file = tmp_path / "eb.json"
@ -35,7 +35,7 @@ class TestRunStatusFill:
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", side_effect=[3, 5]),
patch("screen_locker._status.count_weekly_workouts", side_effect=[3, 6]),
patch("sys.exit"),
):
run_status(locker)
@ -53,7 +53,7 @@ class TestRunStatusFill:
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 5]),
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 6]),
patch("sys.exit"),
):
run_status(locker)
@ -63,7 +63,7 @@ class TestRunStatusFill:
def test_fill_no_bonus_when_still_below_min(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""n_filled=1 but count still < 4, before and after the fill → no bonus."""
"""n_filled=1 but count still < 5, before and after the fill → no bonus."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
with (
@ -87,7 +87,7 @@ class TestRunStatusMinimumStatus:
"""after_count > WEEKLY_WORKOUT_MINIMUM → 'above minimum' line.
n_filled=1 triggers the count_weekly_workouts() branch so after_count
is taken from the second mocked call (5), not the first (before_count).
is taken from the second mocked call (6), not the first (before_count).
"""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
@ -95,7 +95,7 @@ class TestRunStatusMinimumStatus:
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 5]),
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 6]),
patch("sys.exit"),
):
run_status(locker)
@ -107,8 +107,8 @@ class TestRunStatusMinimumStatus:
) -> None:
"""after_count == WEEKLY_WORKOUT_MINIMUM → 'met exactly' line.
n_filled=1 so after_count = count_weekly_workouts() = 4 = WEEKLY_WORKOUT_MINIMUM.
bonus = max(0, 4 - max(4, 0)) = 0, so no bonus line is printed.
n_filled=1 so after_count = count_weekly_workouts() = 5 = WEEKLY_WORKOUT_MINIMUM.
bonus = max(0, 5 - max(5, 0)) = 0, so no bonus line is printed.
"""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
@ -116,7 +116,7 @@ class TestRunStatusMinimumStatus:
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 4]),
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 5]),
patch("sys.exit"),
):
run_status(locker)

View File

@ -284,7 +284,7 @@ class TestBackfillWeekAndApplyBonus:
with patch(
"screen_locker._status_view_verify.count_weekly_workouts",
side_effect=[4, 6],
side_effect=[4, 7],
):
message = _backfill_week_and_apply_bonus(verifier)

View File

@ -189,6 +189,22 @@ class TestCountWeeklyWorkouts:
)
assert count_weekly_workouts(log, today=_dt(4)) == 2
def test_multiple_same_day_manual_workouts_each_count(self, tmp_path: Path) -> None:
"""Manual workouts count individually now, same as verified — no
once-per-day collapse."""
log = tmp_path / "workout_log.json"
log.write_text(
json.dumps(
{
"2025-05-19": [
{"workout_data": {"type": "manual_workout"}},
{"workout_data": {"type": "manual_workout"}},
],
}
)
)
assert count_weekly_workouts(log, today=_dt(4)) == 2
# ---------------------------------------------------------------------------
# has_weekly_minimum
@ -212,7 +228,7 @@ class TestHasWeeklyMinimum:
)
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_four_workouts_is_true(self, tmp_path: Path) -> None:
def test_four_workouts_is_false(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
@ -223,7 +239,7 @@ class TestHasWeeklyMinimum:
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is True
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_five_workouts_is_true(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
@ -240,4 +256,4 @@ class TestHasWeeklyMinimum:
assert has_weekly_minimum(log, today=_dt(4)) is True
def test_weekly_workout_minimum_constant(self) -> None:
assert WEEKLY_WORKOUT_MINIMUM == 4
assert WEEKLY_WORKOUT_MINIMUM == 5

View File

@ -230,7 +230,7 @@ class TestStartRelaxedDayFlow:
all_text = " ".join(str(c) for c in mock_text.call_args_list)
assert "2" in all_text
assert "4" in all_text
assert "5" in all_text
def test_skip_button_wires_close(
self,