From 298daf028dba1c2cb328cc04bef9db864623f4e2 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 19 Jul 2026 13:50:39 +0200 Subject: [PATCH] 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 --- screen_locker/_constants.py | 2 +- screen_locker/_manual_sync.py | 33 +++++- screen_locker/_manual_workout.py | 18 ++-- screen_locker/_weekly_check.py | 33 +++--- screen_locker/_workout_credit.py | 102 ++++++++++-------- screen_locker/screen_lock.py | 34 +++++- screen_locker/tests/test_manual_sync.py | 62 +++++++++++ screen_locker/tests/test_manual_workout.py | 33 ++++-- .../tests/test_manual_workout_features.py | 92 ++++++++++++++-- .../tests/test_screen_lock_coverage_part1.py | 6 +- .../tests/test_screen_lock_coverage_part2.py | 80 ++++++++++++++ screen_locker/tests/test_status.py | 8 +- screen_locker/tests/test_status_data.py | 40 ++++--- screen_locker/tests/test_status_part2.py | 18 ++-- screen_locker/tests/test_status_view.py | 2 +- screen_locker/tests/test_weekly_check.py | 22 +++- screen_locker/tests/test_weekly_logic.py | 2 +- 17 files changed, 461 insertions(+), 126 deletions(-) diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index 514b95f..c799df5 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -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. diff --git a/screen_locker/_manual_sync.py b/screen_locker/_manual_sync.py index ef6f232..57d203c 100644 --- a/screen_locker/_manual_sync.py +++ b/screen_locker/_manual_sync.py @@ -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 diff --git a/screen_locker/_manual_workout.py b/screen_locker/_manual_workout.py index 2c642d0..7ba1ac6 100644 --- a/screen_locker/_manual_workout.py +++ b/screen_locker/_manual_workout.py @@ -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 diff --git a/screen_locker/_weekly_check.py b/screen_locker/_weekly_check.py index 23d8d34..8ddeccf 100644 --- a/screen_locker/_weekly_check.py +++ b/screen_locker/_weekly_check.py @@ -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 diff --git a/screen_locker/_workout_credit.py b/screen_locker/_workout_credit.py index be99c74..5336a4f 100644 --- a/screen_locker/_workout_credit.py +++ b/screen_locker/_workout_credit.py @@ -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) diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 5872eb4..6802d71 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -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.""" diff --git a/screen_locker/tests/test_manual_sync.py b/screen_locker/tests/test_manual_sync.py index 8a3507a..1be2b07 100644 --- a/screen_locker/tests/test_manual_sync.py +++ b/screen_locker/tests/test_manual_sync.py @@ -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 diff --git a/screen_locker/tests/test_manual_workout.py b/screen_locker/tests/test_manual_workout.py index b447aeb..95955df 100644 --- a/screen_locker/tests/test_manual_workout.py +++ b/screen_locker/tests/test_manual_workout.py @@ -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 diff --git a/screen_locker/tests/test_manual_workout_features.py b/screen_locker/tests/test_manual_workout_features.py index b9724ca..090d072 100644 --- a/screen_locker/tests/test_manual_workout_features.py +++ b/screen_locker/tests/test_manual_workout_features.py @@ -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: diff --git a/screen_locker/tests/test_screen_lock_coverage_part1.py b/screen_locker/tests/test_screen_lock_coverage_part1.py index a351096..dc2f8d7 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part1.py +++ b/screen_locker/tests/test_screen_lock_coverage_part1.py @@ -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] ), diff --git a/screen_locker/tests/test_screen_lock_coverage_part2.py b/screen_locker/tests/test_screen_lock_coverage_part2.py index 514ef09..f7a9790 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part2.py +++ b/screen_locker/tests/test_screen_lock_coverage_part2.py @@ -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) diff --git a/screen_locker/tests/test_status.py b/screen_locker/tests/test_status.py index 2a0a61f..8b46809 100644 --- a/screen_locker/tests/test_status.py +++ b/screen_locker/tests/test_status.py @@ -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 diff --git a/screen_locker/tests/test_status_data.py b/screen_locker/tests/test_status_data.py index 0585f56..3b685a1 100644 --- a/screen_locker/tests/test_status_data.py +++ b/screen_locker/tests/test_status_data.py @@ -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", + ) } ) ) diff --git a/screen_locker/tests/test_status_part2.py b/screen_locker/tests/test_status_part2.py index bd14829..30233f1 100644 --- a/screen_locker/tests/test_status_part2.py +++ b/screen_locker/tests/test_status_part2.py @@ -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) diff --git a/screen_locker/tests/test_status_view.py b/screen_locker/tests/test_status_view.py index 5f6902a..abf5e47 100644 --- a/screen_locker/tests/test_status_view.py +++ b/screen_locker/tests/test_status_view.py @@ -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) diff --git a/screen_locker/tests/test_weekly_check.py b/screen_locker/tests/test_weekly_check.py index ca98ffa..312cef9 100644 --- a/screen_locker/tests/test_weekly_check.py +++ b/screen_locker/tests/test_weekly_check.py @@ -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 diff --git a/screen_locker/tests/test_weekly_logic.py b/screen_locker/tests/test_weekly_logic.py index 20ed460..15ab412 100644 --- a/screen_locker/tests/test_weekly_logic.py +++ b/screen_locker/tests/test_weekly_logic.py @@ -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,