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,