"""Tests for manual-workout integration in _ui_flows.py and screen_lock.py.""" from __future__ import annotations from datetime import date, datetime, time import pathlib from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest from screen_locker._day import today_str from screen_locker._log_mixin import RecordResult from screen_locker.tests.conftest import create_locker # The Dart mirror of the budget caps; read at test time so drift on # EITHER side fails, not just a local edit to the Python constants. DART_MANUAL_WORKOUT = ( pathlib.Path(__file__).resolve().parents[2] / "stronglift_replacement" / "workout_app" / "lib" / "models" / "manual_workout.dart" ) if TYPE_CHECKING: from pathlib import Path def _today_at(hhmm: str) -> float: """Unix time of ``hhmm`` local on the locker's today.""" hours, minutes = (int(p) for p in hhmm.split(":")) day = date.fromisoformat(today_str()) return datetime.combine(day, time(hours, minutes)).astimezone().timestamp() @pytest.mark.usefixtures("pre_ladder") class TestApplyWorkoutCredit: """Tests for ScreenLocker._apply_workout_credit under the multi-per-day rule. Credit is now driven by the :class:`RecordResult` the write chokepoint returns (``appended`` + ``prior_entries``), not a separate ``_was_already_counted_today`` probe (removed). """ def test_skips_credit_when_duplicate_already_logged_today( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: """save_workout_log reports a duplicate (appended=False) → no new credit.""" locker = create_locker(mock_tk, tmp_path) locker.workout_data = {"type": "manual_workout"} object.__setattr__( locker, "save_workout_log", MagicMock(return_value=RecordResult(appended=False, prior_entries=[])), ) object.__setattr__( locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True) ) result = locker._apply_workout_credit() assert result.already_counted_today is True assert result.shutdown_adjusted is False assert not result.extra_bonus_delta locker._try_adjust_shutdown_for_workout.assert_not_called() locker.save_workout_log.assert_called_once() # log entry write attempted def test_first_workout_of_day_gets_base_shutdown_push( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: """First counted workout of the day (empty prior_entries) → base +2h push.""" locker = create_locker(mock_tk, tmp_path) locker.workout_data = {"type": "manual_workout"} object.__setattr__( locker, "save_workout_log", MagicMock(return_value=RecordResult(appended=True, prior_entries=[])), ) object.__setattr__( locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True) ) result = locker._apply_workout_credit() assert result.already_counted_today is False assert result.shutdown_adjusted is True assert not result.extra_bonus_delta locker._try_adjust_shutdown_for_workout.assert_called_once() def test_additional_manual_workout_earns_plus_one_hour( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: """A second manual workout on a day that already counted → +1h extra. Manual workouts now stack intra-day shutdown credit exactly like verified ones — the rate budget (not this stacking rule) is the sole limiter on manual workouts. Mirrors ``test_additional_verified_workout_earns_plus_one_hour``. """ locker = create_locker(mock_tk, tmp_path) locker.workout_data = {"type": "manual_workout", "end_time": "17:00"} prior = [{"workout_data": {"type": "manual_workout"}}] object.__setattr__( locker, "save_workout_log", MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)), ) object.__setattr__( locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True) ) object.__setattr__( locker, "_read_shutdown_config", MagicMock( side_effect=[(1260, 1260, 300), (1320, 1320, 300), (1320, 1320, 300)] ), ) object.__setattr__( locker, "_adjust_shutdown_time_by", MagicMock(return_value=True) ) with patch( "screen_locker._workout_credit.count_weekly_workouts", return_value=5 ): result = locker._apply_workout_credit() assert result.weekly_count == 5 assert result.shutdown_adjusted is False assert result.extra_bonus_delta == 60 locker._try_adjust_shutdown_for_workout.assert_not_called() locker._adjust_shutdown_time_by.assert_called_once_with( 60, at=_today_at("17:00") ) def test_additional_verified_workout_earns_plus_one_hour( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: """An additional same-day VERIFIED workout earns the +1h extra bonus. prior_entries already holds a StrongLifts session; a RunnerUp run is a distinct credit slot, so it takes the +1h branch, and extra_bonus_delta reflects the shutdown-config minute delta. """ locker = create_locker(mock_tk, tmp_path) locker.workout_data = {"type": "runnerup_verified"} prior = [{"workout_data": {"type": "phone_verified"}}] object.__setattr__( locker, "save_workout_log", MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)), ) object.__setattr__( locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True) ) object.__setattr__( locker, "_read_shutdown_config", MagicMock( side_effect=[(1260, 1260, 300), (1320, 1320, 300), (1320, 1320, 300)] ), ) object.__setattr__( locker, "_adjust_shutdown_time_by", MagicMock(return_value=True) ) with patch( "screen_locker._workout_credit.count_weekly_workouts", return_value=5 ): result = locker._apply_workout_credit() assert result.weekly_count == 5 assert result.shutdown_adjusted is False assert result.extra_bonus_delta == 60 locker._try_adjust_shutdown_for_workout.assert_not_called() locker._adjust_shutdown_time_by.assert_called_once_with(60, at=None)