diff --git a/screen_locker/tests/test_adb_and_phone.py b/screen_locker/tests/test_adb_and_phone.py index e813826..124a8f1 100644 --- a/screen_locker/tests/test_adb_and_phone.py +++ b/screen_locker/tests/test_adb_and_phone.py @@ -151,106 +151,3 @@ class TestAdbShell: ["shell", "su", "-c", "ls /data"], ) assert success is True - - -class TestIsPhoneConnected: - """Tests for _is_phone_connected method.""" - - def test_phone_connected( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test phone detected as connected.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_run_adb", - MagicMock( - return_value=( - True, - "List of devices attached\nABC123\tdevice\n\n", - ), - ), - ) - - assert locker._is_phone_connected() is True - - def test_phone_not_connected( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test no phone connected.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_run_adb", - MagicMock( - return_value=(True, "List of devices attached\n\n"), - ), - ) - object.__setattr__( - locker, - "_try_wireless_reconnect", - MagicMock( - return_value=False, - ), - ) - - assert locker._is_phone_connected() is False - - def test_phone_offline( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test phone connected but offline.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_run_adb", - MagicMock( - return_value=( - True, - "List of devices attached\nABC123\toffline\n\n", - ), - ), - ) - object.__setattr__( - locker, - "_try_wireless_reconnect", - MagicMock( - return_value=False, - ), - ) - - assert locker._is_phone_connected() is False - - def test_adb_command_fails( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test ADB command failure.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_run_adb", - MagicMock( - return_value=(False, ""), - ), - ) - object.__setattr__( - locker, - "_try_wireless_reconnect", - MagicMock( - return_value=False, - ), - ) - - assert locker._is_phone_connected() is False diff --git a/screen_locker/tests/test_adb_and_phone_part2.py b/screen_locker/tests/test_adb_and_phone_part2.py new file mode 100644 index 0000000..4017d69 --- /dev/null +++ b/screen_locker/tests/test_adb_and_phone_part2.py @@ -0,0 +1,115 @@ +"""Tests for ADB commands, phone connection, and database operations.""" +# pylint: disable=protected-access,unused-argument + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestIsPhoneConnected: + """Tests for _is_phone_connected method.""" + + def test_phone_connected( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test phone detected as connected.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_run_adb", + MagicMock( + return_value=( + True, + "List of devices attached\nABC123\tdevice\n\n", + ), + ), + ) + + assert locker._is_phone_connected() is True + + def test_phone_not_connected( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test no phone connected.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_run_adb", + MagicMock( + return_value=(True, "List of devices attached\n\n"), + ), + ) + object.__setattr__( + locker, + "_try_wireless_reconnect", + MagicMock( + return_value=False, + ), + ) + + assert locker._is_phone_connected() is False + + def test_phone_offline( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test phone connected but offline.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_run_adb", + MagicMock( + return_value=( + True, + "List of devices attached\nABC123\toffline\n\n", + ), + ), + ) + object.__setattr__( + locker, + "_try_wireless_reconnect", + MagicMock( + return_value=False, + ), + ) + + assert locker._is_phone_connected() is False + + def test_adb_command_fails( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test ADB command failure.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_run_adb", + MagicMock( + return_value=(False, ""), + ), + ) + object.__setattr__( + locker, + "_try_wireless_reconnect", + MagicMock( + return_value=False, + ), + ) + + assert locker._is_phone_connected() is False diff --git a/screen_locker/tests/test_compliance_state.py b/screen_locker/tests/test_compliance_state.py index 5e9844c..e85384c 100644 --- a/screen_locker/tests/test_compliance_state.py +++ b/screen_locker/tests/test_compliance_state.py @@ -9,8 +9,6 @@ from unittest.mock import patch from screen_locker import _compliance_state from screen_locker._compliance_state import ( - AutoUpgradeOpportunity, - describe_auto_upgrade_opportunity, has_logged_today, is_early_bird_pending, is_scheduled_skip_today, @@ -30,24 +28,29 @@ class TestIsScheduledSkipToday: """Mirrors test_scheduled_skip.py's coverage, for the standalone function.""" def test_missing_file(self, tmp_path: Path) -> None: + """A missing skips file means today is not a scheduled skip.""" assert is_scheduled_skip_today(tmp_path / "skips.json") is False def test_today_listed(self, tmp_path: Path) -> None: + """Today's date in the skips list makes it a scheduled skip.""" skip_file = tmp_path / "skips.json" skip_file.write_text(json.dumps([_today()])) assert is_scheduled_skip_today(skip_file) is True def test_today_not_listed(self, tmp_path: Path) -> None: + """A list without today is not a scheduled skip.""" skip_file = tmp_path / "skips.json" skip_file.write_text(json.dumps(["1999-01-01"])) assert is_scheduled_skip_today(skip_file) is False def test_corrupt_json(self, tmp_path: Path) -> None: + """Unparsable JSON fails closed: not a skip.""" skip_file = tmp_path / "skips.json" skip_file.write_text("{bad}") assert is_scheduled_skip_today(skip_file) is False def test_explicit_today_override(self, tmp_path: Path) -> None: + """The `today` argument overrides the real date.""" skip_file = tmp_path / "skips.json" skip_file.write_text(json.dumps(["2020-05-05"])) assert is_scheduled_skip_today(skip_file, today="2020-05-05") is True @@ -57,19 +60,23 @@ class TestHasLoggedToday: """Mirrors test_init_and_log.py's HMAC branches, for the standalone function.""" def test_missing_file(self, tmp_path: Path) -> None: + """A missing log means nothing was logged today.""" assert has_logged_today(tmp_path / "log.json") is False def test_corrupt_json(self, tmp_path: Path) -> None: + """Unparsable JSON fails closed: nothing logged.""" log_file = tmp_path / "log.json" log_file.write_text("{bad}") assert has_logged_today(log_file) is False def test_no_entry_for_today(self, tmp_path: Path) -> None: + """An entry for another day does not count as today's.""" log_file = tmp_path / "log.json" log_file.write_text(json.dumps({"1999-01-01": {}})) assert has_logged_today(log_file) is False def test_valid_hmac(self, tmp_path: Path) -> None: + """A correctly signed entry for today counts.""" log_file = tmp_path / "log.json" log_file.write_text(json.dumps({_today(): {"hmac": "sig"}})) with patch( @@ -78,6 +85,7 @@ class TestHasLoggedToday: assert has_logged_today(log_file) is True def test_unsigned_accepted_when_key_unavailable(self, tmp_path: Path) -> None: + """With no HMAC key on the machine, an unsigned entry is accepted.""" log_file = tmp_path / "log.json" log_file.write_text(json.dumps({_today(): {}})) with ( @@ -91,6 +99,7 @@ class TestHasLoggedToday: assert has_logged_today(log_file) is True def test_rejected_when_key_available(self, tmp_path: Path) -> None: + """With a key available, an unsigned entry is rejected as tampering.""" log_file = tmp_path / "log.json" log_file.write_text(json.dumps({_today(): {}})) with ( @@ -104,6 +113,7 @@ class TestHasLoggedToday: assert has_logged_today(log_file) is False def test_rejected_when_signed_but_invalid(self, tmp_path: Path) -> None: + """A signature that does not verify is rejected.""" log_file = tmp_path / "log.json" log_file.write_text(json.dumps({_today(): {"hmac": "tampered"}})) with ( @@ -121,24 +131,29 @@ class TestIsEarlyBirdPending: """Mirrors test_early_bird.py's HMAC branches, for the standalone function.""" def test_missing_file(self, tmp_path: Path) -> None: + """A missing pending file means no early bird is pending.""" assert is_early_bird_pending(tmp_path / "pending.json") is False def test_corrupt_json(self, tmp_path: Path) -> None: + """Unparsable JSON fails closed: nothing pending.""" pending_file = tmp_path / "pending.json" pending_file.write_text("{bad}") assert is_early_bird_pending(pending_file) is False def test_not_a_dict(self, tmp_path: Path) -> None: + """A JSON list where a dict is expected fails closed.""" pending_file = tmp_path / "pending.json" pending_file.write_text(json.dumps(["not", "a", "dict"])) assert is_early_bird_pending(pending_file) is False def test_stale_date(self, tmp_path: Path) -> None: + """A pending record from an earlier day no longer applies.""" pending_file = tmp_path / "pending.json" pending_file.write_text(json.dumps({"date": "2000-01-01", "hmac": "sig"})) assert is_early_bird_pending(pending_file) is False def test_valid_hmac(self, tmp_path: Path) -> None: + """A correctly signed pending record for today counts.""" pending_file = tmp_path / "pending.json" pending_file.write_text(json.dumps({"date": _today(), "hmac": "sig"})) with patch( @@ -147,6 +162,7 @@ class TestIsEarlyBirdPending: assert is_early_bird_pending(pending_file) is True def test_unsigned_accepted_when_key_unavailable(self, tmp_path: Path) -> None: + """With no HMAC key, an unsigned pending record is accepted.""" pending_file = tmp_path / "pending.json" pending_file.write_text(json.dumps({"date": _today()})) with ( @@ -160,6 +176,7 @@ class TestIsEarlyBirdPending: assert is_early_bird_pending(pending_file) is True def test_rejected_when_key_available(self, tmp_path: Path) -> None: + """With a key available, an unsigned pending record is rejected.""" pending_file = tmp_path / "pending.json" pending_file.write_text(json.dumps({"date": _today()})) with ( @@ -173,6 +190,7 @@ class TestIsEarlyBirdPending: assert is_early_bird_pending(pending_file) is False def test_rejected_when_signed_but_invalid(self, tmp_path: Path) -> None: + """A pending record whose signature fails to verify is rejected.""" pending_file = tmp_path / "pending.json" pending_file.write_text(json.dumps({"date": _today(), "hmac": "bad"})) with ( @@ -187,89 +205,69 @@ class TestIsEarlyBirdPending: class TestIsSickDayToday: + """Reading today's sick-day status out of the sick history.""" + """Thin wrapper around _sick_tracker.is_sick_day.""" def test_true_when_listed(self) -> None: + """Today present in the history is a sick day.""" history = SickHistory(sick_days=[_today()]) assert is_sick_day_today(history) is True def test_false_when_not_listed(self) -> None: + """Today absent from the history is not a sick day.""" history = SickHistory(sick_days=["1999-01-01"]) assert is_sick_day_today(history) is False def test_explicit_today_override(self) -> None: + """The `today` argument overrides the real date.""" history = SickHistory(sick_days=["2020-05-05"]) assert is_sick_day_today(history, today="2020-05-05") is True class TestEarlyBirdWindowOpen: + """The early-bird window's start/end boundaries, including the extension.""" + """Direct tests for the module-private, deliberately independent reimplementation.""" def test_before_window(self) -> None: + """Before the start hour the window is shut.""" assert ( _compliance_state._early_bird_window_open(extended=False, local_minutes=299) is False ) def test_at_start(self) -> None: + """The start minute itself is inside the window.""" assert ( _compliance_state._early_bird_window_open(extended=False, local_minutes=300) is True ) def test_before_end(self) -> None: + """A minute before the end is still inside the window.""" assert ( _compliance_state._early_bird_window_open(extended=False, local_minutes=509) is True ) def test_at_end_exclusive(self) -> None: + """The end minute is exclusive: the window is already shut.""" assert ( _compliance_state._early_bird_window_open(extended=False, local_minutes=510) is False ) def test_extended_before_end(self) -> None: + """With the extension earned, the window runs later.""" assert ( _compliance_state._early_bird_window_open(extended=True, local_minutes=539) is True ) def test_extended_at_end_exclusive(self) -> None: + """The extended end is exclusive too.""" assert ( _compliance_state._early_bird_window_open(extended=True, local_minutes=540) is False ) - - -class TestDescribeAutoUpgradeOpportunity: - """The 3 possible outcomes: expired early-bird, sick day, or none.""" - - def test_expired_early_bird(self) -> None: - result = describe_auto_upgrade_opportunity( - early_bird_pending=True, early_bird_window_open=False, is_sick_day=False - ) - assert result == AutoUpgradeOpportunity( - would_attempt=True, via="early_bird_expired", reason=result.reason - ) - - def test_sick_day(self) -> None: - result = describe_auto_upgrade_opportunity( - early_bird_pending=False, early_bird_window_open=False, is_sick_day=True - ) - assert result.would_attempt is True - assert result.via == "sick_day" - - def test_sick_day_takes_second_priority_when_pending_still_open(self) -> None: - """pending+open doesn't count as expired, so sick_day is still checked.""" - result = describe_auto_upgrade_opportunity( - early_bird_pending=True, early_bird_window_open=True, is_sick_day=True - ) - assert result.via == "sick_day" - - def test_none(self) -> None: - result = describe_auto_upgrade_opportunity( - early_bird_pending=False, early_bird_window_open=False, is_sick_day=False - ) - assert result.would_attempt is False - assert result.via == "none" diff --git a/screen_locker/tests/test_compliance_state_part3.py b/screen_locker/tests/test_compliance_state_part3.py new file mode 100644 index 0000000..f1577da --- /dev/null +++ b/screen_locker/tests/test_compliance_state_part3.py @@ -0,0 +1,44 @@ +"""Tests for the pure, read-only predicates in _compliance_state.py.""" + +from __future__ import annotations + +from screen_locker._compliance_state import ( + AutoUpgradeOpportunity, + describe_auto_upgrade_opportunity, +) + + +class TestDescribeAutoUpgradeOpportunity: + """The 3 possible outcomes: expired early-bird, sick day, or none.""" + + def test_expired_early_bird(self) -> None: + """Expired early bird.""" + result = describe_auto_upgrade_opportunity( + early_bird_pending=True, early_bird_window_open=False, is_sick_day=False + ) + assert result == AutoUpgradeOpportunity( + would_attempt=True, via="early_bird_expired", reason=result.reason + ) + + def test_sick_day(self) -> None: + """Sick day.""" + result = describe_auto_upgrade_opportunity( + early_bird_pending=False, early_bird_window_open=False, is_sick_day=True + ) + assert result.would_attempt is True + assert result.via == "sick_day" + + def test_sick_day_takes_second_priority_when_pending_still_open(self) -> None: + """pending+open doesn't count as expired, so sick_day is still checked.""" + result = describe_auto_upgrade_opportunity( + early_bird_pending=True, early_bird_window_open=True, is_sick_day=True + ) + assert result.via == "sick_day" + + def test_none(self) -> None: + """None.""" + result = describe_auto_upgrade_opportunity( + early_bird_pending=False, early_bird_window_open=False, is_sick_day=False + ) + assert result.would_attempt is False + assert result.via == "none" diff --git a/screen_locker/tests/test_extra_benefits.py b/screen_locker/tests/test_extra_benefits.py index 94a38e2..98abf9c 100644 --- a/screen_locker/tests/test_extra_benefits.py +++ b/screen_locker/tests/test_extra_benefits.py @@ -11,10 +11,7 @@ from screen_locker._extra_benefits import ( _load_state, _save_state, current_streak, - has_extended_early_bird, - preview_bonus_if_week_ended_now, process_week_transition, - weekly_shutdown_bonus_hours, ) if TYPE_CHECKING: @@ -238,74 +235,3 @@ class TestCurrentStreak: f = tmp_path / "state.json" f.write_text(json.dumps({"consecutive_5plus_weeks": 5})) assert current_streak(f) == 5 - - -class TestWeeklyShutdownBonusHours: - """Tests for weekly_shutdown_bonus_hours.""" - - def test_returns_zero_when_missing(self, tmp_path: Path) -> None: - """No state file → 0.""" - f = tmp_path / "state.json" - assert weekly_shutdown_bonus_hours(f) == 0 - - def test_returns_current_week_bonus(self, tmp_path: Path) -> None: - """Returns the banked bonus for the current ISO week.""" - now = datetime.now(tz=timezone.utc).astimezone() - year, week, _ = now.isocalendar() - current_week = f"{year}-W{week:02d}" - f = tmp_path / "state.json" - f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {current_week: 3}})) - assert weekly_shutdown_bonus_hours(f) == 3 - - def test_ignores_other_weeks(self, tmp_path: Path) -> None: - """A bonus banked for a different ISO week is not returned.""" - f = tmp_path / "state.json" - f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2020-W01": 5}})) - assert weekly_shutdown_bonus_hours(f) == 0 - - -class TestPreviewBonusIfWeekEndedNow: - """Tests for preview_bonus_if_week_ended_now — pure threshold math.""" - - def test_below_threshold_returns_zero(self) -> None: - """< 5 workouts: no streak increment, no bonus (line 67 branch).""" - assert preview_bonus_if_week_ended_now(3, current_streak=2) == (0, 0) - - def test_at_threshold_awards_streak_and_bonus(self) -> None: - """Exactly 5 workouts: streak+1, bonus = count - 4.""" - assert preview_bonus_if_week_ended_now(5, current_streak=0) == (1, 1) - - def test_above_threshold_awards_larger_bonus(self) -> None: - """7 workouts: bonus = 7 - 4 = 3.""" - assert preview_bonus_if_week_ended_now(7, current_streak=0) == (1, 3) - - def test_milestone_streak_adds_extra_hour(self) -> None: - """Streak reaching a multiple of 4 adds +1h on top of the base bonus.""" - assert preview_bonus_if_week_ended_now(5, current_streak=3) == (4, 2) - - -class TestHasExtendedEarlyBird: - """Tests for has_extended_early_bird.""" - - def test_returns_false_when_current_week_not_in_list(self, tmp_path: Path) -> None: - """Current ISO week absent from list → False.""" - f = tmp_path / "state.json" - f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2020-W01"]})) - assert has_extended_early_bird(f) is False - - def test_returns_true_when_current_week_is_in_list(self, tmp_path: Path) -> None: - """Current ISO week present in list → True.""" - now = datetime.now(tz=timezone.utc).astimezone() - year, week, _ = now.isocalendar() - current_week = f"{year}-W{week:02d}" - f = tmp_path / "state.json" - f.write_text(json.dumps({"extended_early_bird_iso_weeks": [current_week]})) - assert has_extended_early_bird(f) is True - - def test_explicit_today_override(self, tmp_path: Path) -> None: - """An explicit `today` is used instead of the real wall clock.""" - f = tmp_path / "state.json" - f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2024-W01"]})) - fixed_today = datetime(2024, 1, 5, tzinfo=timezone.utc) - assert has_extended_early_bird(f, today=fixed_today) is True - assert has_extended_early_bird(f) is False diff --git a/screen_locker/tests/test_extra_benefits_part2.py b/screen_locker/tests/test_extra_benefits_part2.py new file mode 100644 index 0000000..e69af61 --- /dev/null +++ b/screen_locker/tests/test_extra_benefits_part2.py @@ -0,0 +1,87 @@ +"""Tests for _extra_benefits module (streak, shutdown bonus, EB extension).""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import TYPE_CHECKING + +from screen_locker._extra_benefits import ( + has_extended_early_bird, + preview_bonus_if_week_ended_now, + weekly_shutdown_bonus_hours, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestWeeklyShutdownBonusHours: + """Tests for weekly_shutdown_bonus_hours.""" + + def test_returns_zero_when_missing(self, tmp_path: Path) -> None: + """No state file → 0.""" + f = tmp_path / "state.json" + assert weekly_shutdown_bonus_hours(f) == 0 + + def test_returns_current_week_bonus(self, tmp_path: Path) -> None: + """Returns the banked bonus for the current ISO week.""" + now = datetime.now(tz=timezone.utc).astimezone() + year, week, _ = now.isocalendar() + current_week = f"{year}-W{week:02d}" + f = tmp_path / "state.json" + f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {current_week: 3}})) + assert weekly_shutdown_bonus_hours(f) == 3 + + def test_ignores_other_weeks(self, tmp_path: Path) -> None: + """A bonus banked for a different ISO week is not returned.""" + f = tmp_path / "state.json" + f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2020-W01": 5}})) + assert weekly_shutdown_bonus_hours(f) == 0 + + +class TestPreviewBonusIfWeekEndedNow: + """Tests for preview_bonus_if_week_ended_now — pure threshold math.""" + + def test_below_threshold_returns_zero(self) -> None: + """< 5 workouts: no streak increment, no bonus (line 67 branch).""" + assert preview_bonus_if_week_ended_now(3, current_streak=2) == (0, 0) + + def test_at_threshold_awards_streak_and_bonus(self) -> None: + """Exactly 5 workouts: streak+1, bonus = count - 4.""" + assert preview_bonus_if_week_ended_now(5, current_streak=0) == (1, 1) + + def test_above_threshold_awards_larger_bonus(self) -> None: + """7 workouts: bonus = 7 - 4 = 3.""" + assert preview_bonus_if_week_ended_now(7, current_streak=0) == (1, 3) + + def test_milestone_streak_adds_extra_hour(self) -> None: + """Streak reaching a multiple of 4 adds +1h on top of the base bonus.""" + assert preview_bonus_if_week_ended_now(5, current_streak=3) == (4, 2) + + +class TestHasExtendedEarlyBird: + """Tests for has_extended_early_bird.""" + + def test_returns_false_when_current_week_not_in_list(self, tmp_path: Path) -> None: + """Current ISO week absent from list → False.""" + f = tmp_path / "state.json" + f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2020-W01"]})) + assert has_extended_early_bird(f) is False + + def test_returns_true_when_current_week_is_in_list(self, tmp_path: Path) -> None: + """Current ISO week present in list → True.""" + now = datetime.now(tz=timezone.utc).astimezone() + year, week, _ = now.isocalendar() + current_week = f"{year}-W{week:02d}" + f = tmp_path / "state.json" + f.write_text(json.dumps({"extended_early_bird_iso_weeks": [current_week]})) + assert has_extended_early_bird(f) is True + + def test_explicit_today_override(self, tmp_path: Path) -> None: + """An explicit `today` is used instead of the real wall clock.""" + f = tmp_path / "state.json" + f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2024-W01"]})) + fixed_today = datetime(2024, 1, 5, tzinfo=timezone.utc) + assert has_extended_early_bird(f, today=fixed_today) is True + assert has_extended_early_bird(f) is False diff --git a/screen_locker/tests/test_manual_push.py b/screen_locker/tests/test_manual_push.py index d640459..d18029d 100644 --- a/screen_locker/tests/test_manual_push.py +++ b/screen_locker/tests/test_manual_push.py @@ -6,24 +6,18 @@ from __future__ import annotations from datetime import datetime, timezone import json from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch +from unittest.mock import patch -from crdt_sync import GitHubSyncError, RepoNotFoundError - -from screen_locker import _manual_push from screen_locker._manual_push import ( - PushResult, _decode_log, _encode_log, _entry_wall_ms, - push_pc_workouts, records_from_workout_log, ) if TYPE_CHECKING: from pathlib import Path - import pytest _MANUAL = { "type": "manual_workout", @@ -53,6 +47,7 @@ class TestEncodeDecodeLog: """The crdt log blob round-trips.""" def test_round_trips(self, tmp_path: Path) -> None: + """Round trips.""" log_file = tmp_path / "workout_log.json" _write_log( log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} @@ -65,6 +60,7 @@ class TestEntryWallMs: """The HLC clock is derived from the entry's own timestamp — stably.""" def test_uses_the_entry_timestamp(self) -> None: + """Uses the entry timestamp.""" expected = int( datetime(2026, 7, 13, 12, tzinfo=timezone.utc).timestamp() * 1000 ) @@ -72,10 +68,12 @@ class TestEntryWallMs: assert ms == expected def test_unparsable_timestamp_falls_back_to_midnight(self) -> None: + """Unparsable timestamp falls back to midnight.""" ms = _entry_wall_ms({"timestamp": "not-a-time"}, "2026-07-13") assert ms == _entry_wall_ms({"timestamp": "2026-07-13T00:00:00+00:00"}, "x") def test_missing_timestamp_falls_back_to_midnight(self) -> None: + """Missing timestamp falls back to midnight.""" expected = int(datetime(2026, 7, 13, tzinfo=timezone.utc).timestamp() * 1000) assert _entry_wall_ms({}, "2026-07-13") == expected @@ -88,6 +86,7 @@ class TestRecordsFromWorkoutLog: """workout_log.json is the single source of truth for what gets pushed.""" def test_derives_a_record_per_counted_workout(self, tmp_path: Path) -> None: + """Derives a record per counted workout.""" log_file = tmp_path / "workout_log.json" _write_log( log_file, @@ -106,6 +105,7 @@ class TestRecordsFromWorkoutLog: assert set(log) == {"manual:2026-07-13T18:00", "runnerup_verified:2026-07-13"} def test_payload_carries_kind_and_date(self, tmp_path: Path) -> None: + """Payload carries kind and date.""" log_file = tmp_path / "workout_log.json" _write_log( log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} @@ -128,6 +128,7 @@ class TestRecordsFromWorkoutLog: assert "manual:2026-07-13T18:00" in records_from_workout_log(log_file) def test_uncounted_types_are_not_pushed(self, tmp_path: Path) -> None: + """Uncounted types are not pushed.""" log_file = tmp_path / "workout_log.json" _write_log( log_file, @@ -136,6 +137,7 @@ class TestRecordsFromWorkoutLog: assert records_from_workout_log(log_file) == {} def test_non_dict_workout_data_is_skipped(self, tmp_path: Path) -> None: + """Non dict workout data is skipped.""" log_file = tmp_path / "workout_log.json" _write_log( log_file, @@ -165,130 +167,3 @@ class TestRecordsFromWorkoutLog: assert _encode_log(records_from_workout_log(log_file)) == _encode_log( records_from_workout_log(log_file) ) - - -class TestPushPcWorkouts: - """Every outcome is reported — never a silent no-op.""" - - def test_no_token_reports_why(self, tmp_path: Path) -> None: - log_file = tmp_path / "workout_log.json" - _write_log( - log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} - ) - with patch.object(_manual_push, "read_sync_token", return_value=None): - result = push_pc_workouts(log_file) - assert result == PushResult(pushed=False, record_count=0, reason=result.reason) - assert "no sync token" in result.reason - - def test_empty_log_reports_why(self, tmp_path: Path) -> None: - log_file = tmp_path / "workout_log.json" - _write_log(log_file, {}) - with patch.object(_manual_push, "read_sync_token", return_value="t"): - result = push_pc_workouts(log_file) - assert result.pushed is False - assert result.record_count == 0 - assert "no counted workouts" in result.reason - - def test_sync_error_reports_why(self, tmp_path: Path) -> None: - log_file = tmp_path / "workout_log.json" - _write_log( - log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} - ) - with ( - patch.object(_manual_push, "read_sync_token", return_value="t"), - patch.object(_manual_push, "GitHubSyncClient", MagicMock()), - patch.object( - _manual_push, "sync_log", side_effect=GitHubSyncError("403 forbidden") - ), - ): - result = push_pc_workouts(log_file) - assert result.pushed is False - assert result.record_count == 1 - assert "403 forbidden" in result.reason - - def test_network_error_is_not_blamed_on_the_token( - self, tmp_path: Path, caplog: pytest.LogCaptureFixture - ) -> None: - """A network failure must not be reported as a permissions problem. - - Regression guard for 2026-07-20: the morning routine runs seconds after - boot/resume, so its pushes failed with the network still down — and the - log asserted "a 403 here means the token lacks contents:write", which - sent an investigation chasing a permissions bug that did not exist. - """ - log_file = tmp_path / "workout_log.json" - _write_log( - log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} - ) - with ( - patch.object(_manual_push, "read_sync_token", return_value="t"), - patch.object(_manual_push, "GitHubSyncClient", MagicMock()), - patch.object( - _manual_push, - "sync_log", - side_effect=GitHubSyncError("network error reading x"), - ), - caplog.at_level("WARNING"), - ): - result = push_pc_workouts(log_file) - assert result.pushed is False - assert "network error reading x" in caplog.text - assert "contents:write" not in caplog.text - - def test_repo_not_found_still_points_at_the_token( - self, tmp_path: Path, caplog: pytest.LogCaptureFixture - ) -> None: - """The permission hint survives for the error that actually implies it.""" - log_file = tmp_path / "workout_log.json" - _write_log( - log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} - ) - with ( - patch.object(_manual_push, "read_sync_token", return_value="t"), - patch.object(_manual_push, "GitHubSyncClient", MagicMock()), - patch.object( - _manual_push, - "sync_log", - side_effect=RepoNotFoundError("no access"), - ), - caplog.at_level("WARNING"), - ): - result = push_pc_workouts(log_file) - assert result.pushed is False - assert "contents:write" in caplog.text - - def test_successful_push_reports_count(self, tmp_path: Path) -> None: - log_file = tmp_path / "workout_log.json" - _write_log( - log_file, - { - "2026-07-13": [ - _entry(_MANUAL, "2026-07-13T07:25:12+00:00"), - _entry(_RUN, "2026-07-13T23:10:00+00:00"), - ] - }, - ) - fake_sync = MagicMock() - with ( - patch.object(_manual_push, "read_sync_token", return_value="t"), - patch.object(_manual_push, "GitHubSyncClient", MagicMock()), - patch.object(_manual_push, "sync_log", fake_sync), - ): - result = push_pc_workouts(log_file) - assert result == PushResult(pushed=True, record_count=2, reason="pushed") - assert fake_sync.call_count == 1 - - def test_pushes_runs_not_just_manuals(self, tmp_path: Path) -> None: - """Regression: the phone must converge on the PC's runs too.""" - log_file = tmp_path / "workout_log.json" - _write_log( - log_file, {"2026-07-12": [_entry(_RUN, "2026-07-12T10:00:00+00:00")]} - ) - with ( - patch.object(_manual_push, "read_sync_token", return_value="t"), - patch.object(_manual_push, "GitHubSyncClient", MagicMock()), - patch.object(_manual_push, "sync_log", MagicMock()) as fake_sync, - ): - push_pc_workouts(log_file) - pushed = fake_sync.call_args.kwargs["local_log"] - assert "runnerup_verified:2026-07-12" in pushed diff --git a/screen_locker/tests/test_manual_push_part2.py b/screen_locker/tests/test_manual_push_part2.py new file mode 100644 index 0000000..f2c1677 --- /dev/null +++ b/screen_locker/tests/test_manual_push_part2.py @@ -0,0 +1,168 @@ +"""Tests for push_pc_workouts. + +Split out of test_manual_push.py for the 250-line cap; the shared _entry / +_write_log helpers stay in that module and are imported here. +""" +# pylint: disable=protected-access + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from crdt_sync import GitHubSyncError, RepoNotFoundError + +from screen_locker import _manual_push +from screen_locker._manual_push import ( + PushResult, + push_pc_workouts, +) +from screen_locker.tests.test_manual_push import _entry, _write_log + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + +_MANUAL = { + "type": "manual_workout", + "source": "table tennis at Solec", + "start_time": "18:00", + "rpe": 6, +} +_RUN = { + "type": "runnerup_verified", + "source": "Running: 9.8 km in 55 min", + "distance_km": 9.8, +} + + +class TestPushPcWorkouts: + """Every outcome is reported — never a silent no-op.""" + + def test_no_token_reports_why(self, tmp_path: Path) -> None: + """No token reports why.""" + log_file = tmp_path / "workout_log.json" + _write_log( + log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} + ) + with patch.object(_manual_push, "read_sync_token", return_value=None): + result = push_pc_workouts(log_file) + assert result == PushResult(pushed=False, record_count=0, reason=result.reason) + assert "no sync token" in result.reason + + def test_empty_log_reports_why(self, tmp_path: Path) -> None: + """Empty log reports why.""" + log_file = tmp_path / "workout_log.json" + _write_log(log_file, {}) + with patch.object(_manual_push, "read_sync_token", return_value="t"): + result = push_pc_workouts(log_file) + assert result.pushed is False + assert result.record_count == 0 + assert "no counted workouts" in result.reason + + def test_sync_error_reports_why(self, tmp_path: Path) -> None: + """Sync error reports why.""" + log_file = tmp_path / "workout_log.json" + _write_log( + log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} + ) + with ( + patch.object(_manual_push, "read_sync_token", return_value="t"), + patch.object(_manual_push, "GitHubSyncClient", MagicMock()), + patch.object( + _manual_push, "sync_log", side_effect=GitHubSyncError("403 forbidden") + ), + ): + result = push_pc_workouts(log_file) + assert result.pushed is False + assert result.record_count == 1 + assert "403 forbidden" in result.reason + + def test_network_error_is_not_blamed_on_the_token( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """A network failure must not be reported as a permissions problem. + + Regression guard for 2026-07-20: the morning routine runs seconds after + boot/resume, so its pushes failed with the network still down — and the + log asserted "a 403 here means the token lacks contents:write", which + sent an investigation chasing a permissions bug that did not exist. + """ + log_file = tmp_path / "workout_log.json" + _write_log( + log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} + ) + with ( + patch.object(_manual_push, "read_sync_token", return_value="t"), + patch.object(_manual_push, "GitHubSyncClient", MagicMock()), + patch.object( + _manual_push, + "sync_log", + side_effect=GitHubSyncError("network error reading x"), + ), + caplog.at_level("WARNING"), + ): + result = push_pc_workouts(log_file) + assert result.pushed is False + assert "network error reading x" in caplog.text + assert "contents:write" not in caplog.text + + def test_repo_not_found_still_points_at_the_token( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """The permission hint survives for the error that actually implies it.""" + log_file = tmp_path / "workout_log.json" + _write_log( + log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} + ) + with ( + patch.object(_manual_push, "read_sync_token", return_value="t"), + patch.object(_manual_push, "GitHubSyncClient", MagicMock()), + patch.object( + _manual_push, + "sync_log", + side_effect=RepoNotFoundError("no access"), + ), + caplog.at_level("WARNING"), + ): + result = push_pc_workouts(log_file) + assert result.pushed is False + assert "contents:write" in caplog.text + + def test_successful_push_reports_count(self, tmp_path: Path) -> None: + """Successful push reports count.""" + log_file = tmp_path / "workout_log.json" + _write_log( + log_file, + { + "2026-07-13": [ + _entry(_MANUAL, "2026-07-13T07:25:12+00:00"), + _entry(_RUN, "2026-07-13T23:10:00+00:00"), + ] + }, + ) + fake_sync = MagicMock() + with ( + patch.object(_manual_push, "read_sync_token", return_value="t"), + patch.object(_manual_push, "GitHubSyncClient", MagicMock()), + patch.object(_manual_push, "sync_log", fake_sync), + ): + result = push_pc_workouts(log_file) + assert result == PushResult(pushed=True, record_count=2, reason="pushed") + assert fake_sync.call_count == 1 + + def test_pushes_runs_not_just_manuals(self, tmp_path: Path) -> None: + """Regression: the phone must converge on the PC's runs too.""" + log_file = tmp_path / "workout_log.json" + _write_log( + log_file, {"2026-07-12": [_entry(_RUN, "2026-07-12T10:00:00+00:00")]} + ) + with ( + patch.object(_manual_push, "read_sync_token", return_value="t"), + patch.object(_manual_push, "GitHubSyncClient", MagicMock()), + patch.object(_manual_push, "sync_log", MagicMock()) as fake_sync, + ): + push_pc_workouts(log_file) + pushed = fake_sync.call_args.kwargs["local_log"] + assert "runnerup_verified:2026-07-12" in pushed diff --git a/screen_locker/tests/test_manual_sync.py b/screen_locker/tests/test_manual_sync.py index 1be2b07..f51ac74 100644 --- a/screen_locker/tests/test_manual_sync.py +++ b/screen_locker/tests/test_manual_sync.py @@ -69,7 +69,10 @@ def _manual_record(draft: ManualWorkoutDraft, date: str = _DATE) -> tuple[str, d class TestReconstructDraft: + """ReconstructDraft.""" + def test_rebuilds_a_table_tennis_draft(self) -> None: + """Rebuilds a table tennis draft.""" _, payload = _manual_record(_TT_DRAFT) draft = reconstruct_draft(payload) assert draft is not None @@ -77,24 +80,30 @@ class TestReconstructDraft: assert draft.racket == "Butterfly" def test_rebuilds_an_other_sport_draft(self) -> None: + """Rebuilds an other sport draft.""" _, payload = _manual_record(_OTHER_DRAFT) draft = reconstruct_draft(payload) assert draft is not None assert draft.activity_type_other == "squash" def test_returns_none_when_a_required_field_is_missing(self) -> None: + """Returns none when a required field is missing.""" _, payload = _manual_record(_TT_DRAFT) del payload["start_time"] assert reconstruct_draft(payload) is None def test_returns_none_on_a_mistyped_field(self) -> None: + """Returns none on a mistyped field.""" _, payload = _manual_record(_TT_DRAFT) payload["rpe"] = None assert reconstruct_draft(payload) is None class TestIngestManualRecords: + """IngestManualRecords.""" + def test_ingests_a_valid_record(self, tmp_path: Path) -> None: + """Ingests a valid record.""" log_file = tmp_path / "workout_log.json" record = _manual_record(_TT_DRAFT) ingested = ingest_manual_records(log_file, [record], today=_DATE) @@ -107,6 +116,7 @@ class TestIngestManualRecords: assert count_weekly_workouts(log_file, today=_dt(_DATE)) == 1 def test_is_idempotent(self, tmp_path: Path) -> None: + """Is idempotent.""" log_file = tmp_path / "workout_log.json" record = _manual_record(_TT_DRAFT) ingest_manual_records(log_file, [record], today=_DATE) @@ -114,6 +124,7 @@ class TestIngestManualRecords: assert again == [] def test_skips_a_non_manual_payload(self, tmp_path: Path) -> None: + """Skips a non manual payload.""" log_file = tmp_path / "workout_log.json" assert ( ingest_manual_records(log_file, [("s", {"succeeded": True})], today=_DATE) @@ -121,6 +132,7 @@ class TestIngestManualRecords: ) def test_skips_a_record_without_a_date(self, tmp_path: Path) -> None: + """Skips a record without a date.""" log_file = tmp_path / "workout_log.json" _, payload = _manual_record(_TT_DRAFT) del payload["date"] @@ -145,18 +157,21 @@ class TestIngestManualRecords: assert stored[_DATE][1]["workout_data"]["type"] == "manual_workout" def test_skips_a_malformed_payload(self, tmp_path: Path) -> None: + """Skips a malformed payload.""" log_file = tmp_path / "workout_log.json" _, payload = _manual_record(_TT_DRAFT) payload["rpe"] = None # reconstruct_draft -> None assert ingest_manual_records(log_file, [("m", payload)], today=_DATE) == [] def test_skips_an_invalid_draft(self, tmp_path: Path) -> None: + """Skips an invalid draft.""" log_file = tmp_path / "workout_log.json" _, payload = _manual_record(_TT_DRAFT) payload["location_name"] = " " # fails validate_manual_workout assert ingest_manual_records(log_file, [("m", payload)], today=_DATE) == [] def test_skips_when_budget_exhausted(self, tmp_path: Path) -> None: + """Skips when budget exhausted.""" log_file = tmp_path / "workout_log.json" record = _manual_record(_TT_DRAFT) with patch.object(_manual_sync, "is_budget_exhausted", return_value=True): @@ -165,6 +180,7 @@ class TestIngestManualRecords: def test_writes_unsigned_entry_when_hmac_key_unavailable( self, tmp_path: Path ) -> None: + """Writes unsigned entry when HMAC key unavailable.""" log_file = tmp_path / "workout_log.json" record = _manual_record(_TT_DRAFT) with patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None): @@ -179,6 +195,7 @@ class TestIngestManualRecordsCallback: def test_on_ingested_called_with_entry_and_prior_entries( self, tmp_path: Path ) -> None: + """On ingested called with entry and prior entries.""" log_file = tmp_path / "workout_log.json" log_file.write_text( json.dumps({_DATE: [{"workout_data": {"type": "phone_verified"}}]}) @@ -198,6 +215,7 @@ class TestIngestManualRecordsCallback: assert prior == [{"workout_data": {"type": "phone_verified"}}] def test_on_ingested_not_called_when_budget_exhausted(self, tmp_path: Path) -> None: + """On ingested not called when budget exhausted.""" log_file = tmp_path / "workout_log.json" record = _manual_record(_TT_DRAFT) calls: list[tuple[dict, list[dict]]] = [] @@ -234,26 +252,6 @@ class TestIngestManualRecordsCallback: assert calls == [] -class TestIngestHelpers: - def test_already_ingested_finds_id_in_per_day_lists(self) -> None: - """_already_ingested takes normalized ``{date: [entry, ...]}`` and - scans every entry across all days for a matching sync record id.""" - logs = { - "a": [], - "b": [ - {"workout_data": {"type": "manual_workout"}}, - {"workout_data": {"sync_record_id": "x"}}, - ], - } - assert _manual_sync._already_ingested(logs, "x") is True - assert _manual_sync._already_ingested(logs, "y") is False - - def test_already_ingested_ignores_non_dict_workout_data(self) -> None: - """An entry whose workout_data isn't a dict is skipped, not matched.""" - logs = {"b": [{"workout_data": "not-a-dict"}]} - assert _manual_sync._already_ingested(logs, "x") is False - - def _dt(date: str) -> object: from datetime import datetime, timezone diff --git a/screen_locker/tests/test_manual_sync_part2.py b/screen_locker/tests/test_manual_sync_part2.py new file mode 100644 index 0000000..262cff8 --- /dev/null +++ b/screen_locker/tests/test_manual_sync_part2.py @@ -0,0 +1,69 @@ +"""Tests for ingesting synced manual workouts into workout_log.json.""" +# pylint: disable=protected-access + +from __future__ import annotations + +from screen_locker import _manual_sync +from screen_locker._manual_workout import ( + SPORT_OTHER, + SPORT_TABLE_TENNIS, + ManualWorkoutDraft, +) + +_DATE = "2026-07-13" + +_TT_DRAFT = ManualWorkoutDraft( + sport=SPORT_TABLE_TENNIS, + start_time="18:00", + end_time="19:30", + location_name="Solec", + transport_method="bike", + cost="40 PLN", + rpe=6, + went_well="Served consistently and moved feet well", + to_improve="Backhand topspin needs more consistency", + overall_feeling="Felt strong focused and in good rhythm", + matches_won=3, + matches_lost=1, + sets_won=7, + sets_lost=4, + racket="Butterfly", + balls="Nittaku 3-star", +) +_OTHER_DRAFT = ManualWorkoutDraft( + sport=SPORT_OTHER, + start_time="09:00", + end_time="10:00", + location_name="Squash club", + transport_method="car", + cost="30 PLN", + rpe=5, + went_well="Moved well and kept long rallies going", + to_improve="Need to volley earlier off the back wall", + overall_feeling="Tired but satisfied with the effort", + activity_type_other="squash", + activity_details="Full-court squash drills and three practice games total", + equipment="racket, goggles", +) + + +class TestIngestHelpers: + """The normalization/dedup helpers behind ingest_manual_records.""" + + def test_already_ingested_finds_id_in_per_day_lists(self) -> None: + """_already_ingested takes normalized ``{date: [entry, ...]}`` and + scans every entry across all days for a matching sync record id.""" + logs = { + "a": [], + "b": [ + {"workout_data": {"type": "manual_workout"}}, + {"workout_data": {"sync_record_id": "x"}}, + ], + } + assert _manual_sync._already_ingested(logs, "x") is True + assert _manual_sync._already_ingested(logs, "y") is False + + def test_already_ingested_ignores_non_dict_workout_data(self) -> None: + """An entry whose workout_data isn't a dict is skipped, not matched.""" + logs = {"b": [{"workout_data": "not-a-dict"}]} + assert _manual_sync._already_ingested(logs, "x") is False diff --git a/screen_locker/tests/test_phone_verification_part2.py b/screen_locker/tests/test_phone_verification_part2.py index 2fcc64c..08d67d5 100644 --- a/screen_locker/tests/test_phone_verification_part2.py +++ b/screen_locker/tests/test_phone_verification_part2.py @@ -164,105 +164,3 @@ class TestGetLocalSubnetPrefix: ): result = locker._get_local_subnet_prefix() assert result is None - - -class TestTryWirelessReconnect: - """Tests for _try_wireless_reconnect method.""" - - def test_returns_false_when_no_prefix( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False when subnet prefix can't be determined.""" - locker = create_locker(mock_tk, tmp_path) - with patch.object(locker, "_get_local_subnet_prefix", return_value=None): - result = locker._try_wireless_reconnect() - assert result is False - - def test_returns_true_when_probe_succeeds( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns True when a probe finds the phone.""" - locker = create_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), - patch.object(locker, "_try_adb_connect", return_value=True), - patch.object(locker, "_has_adb_device", return_value=True), - patch( - "screen_locker._phone_verification.socket.create_connection", - ) as mock_conn, - ): - mock_sock = MagicMock() - mock_sock.__enter__ = MagicMock(return_value=mock_sock) - mock_sock.__exit__ = MagicMock(return_value=False) - mock_conn.return_value = mock_sock - result = locker._try_wireless_reconnect() - assert result is True - - def test_returns_false_when_no_probe_succeeds( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False when no probe finds the phone.""" - locker = create_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), - patch( - "screen_locker._phone_verification.socket.create_connection", - side_effect=OSError("refused"), - ), - ): - result = locker._try_wireless_reconnect() - assert result is False - - def test_probe_connect_succeeds_but_no_device( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test probe passes socket but adb_connect succeeds without device.""" - locker = create_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), - patch.object(locker, "_try_adb_connect", return_value=True), - patch.object(locker, "_has_adb_device", return_value=False), - patch( - "screen_locker._phone_verification.socket.create_connection", - ) as mock_conn, - ): - mock_sock = MagicMock() - mock_sock.__enter__ = MagicMock(return_value=mock_sock) - mock_sock.__exit__ = MagicMock(return_value=False) - mock_conn.return_value = mock_sock - result = locker._try_wireless_reconnect() - assert result is False - - def test_probe_adb_connect_fails( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test probe where socket connects but adb connect fails.""" - locker = create_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), - patch.object(locker, "_try_adb_connect", return_value=False), - patch( - "screen_locker._phone_verification.socket.create_connection", - ) as mock_conn, - ): - mock_sock = MagicMock() - mock_sock.__enter__ = MagicMock(return_value=mock_sock) - mock_sock.__exit__ = MagicMock(return_value=False) - mock_conn.return_value = mock_sock - result = locker._try_wireless_reconnect() - assert result is False diff --git a/screen_locker/tests/test_phone_verification_part4.py b/screen_locker/tests/test_phone_verification_part4.py index 21a2b02..876c703 100644 --- a/screen_locker/tests/test_phone_verification_part4.py +++ b/screen_locker/tests/test_phone_verification_part4.py @@ -41,6 +41,7 @@ class TestValidateJsonData: def test_stale_when_not_today( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Stale when not today.""" locker = create_locker(mock_tk, tmp_path) status, message = locker._validate_json_data( {"date": "2000-01-01", "exercises": ["x"], "duration_seconds": 4000} @@ -51,6 +52,7 @@ class TestValidateJsonData: def test_no_exercises_when_empty( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """No exercises when empty.""" locker = create_locker(mock_tk, tmp_path) status, message = locker._validate_json_data( {"date": _today(), "exercises": [], "duration_seconds": 4000} @@ -61,6 +63,7 @@ class TestValidateJsonData: def test_too_short_under_minimum( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Too short under minimum.""" locker = create_locker(mock_tk, tmp_path) # Derive from the ACCEPT bar, not the advertised one: a duration # merely under 40 may still clear the hidden leeway and verify. @@ -76,6 +79,7 @@ class TestValidateJsonData: def test_verified_all_succeeded( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Verified all succeeded.""" locker = create_locker(mock_tk, tmp_path) status, message = locker._validate_json_data( { @@ -91,6 +95,7 @@ class TestValidateJsonData: def test_verified_partial_when_not_succeeded( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Verified partial when not succeeded.""" locker = create_locker(mock_tk, tmp_path) status, message = locker._validate_json_data( { @@ -110,6 +115,7 @@ class TestScanForHttpServer: def test_returns_none_without_prefix( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Returns none without prefix.""" locker = create_locker(mock_tk, tmp_path) with patch.object(locker, "_get_local_subnet_prefix", return_value=None): assert locker._scan_for_http_server() is None @@ -117,6 +123,7 @@ class TestScanForHttpServer: def test_returns_url_when_probe_connects( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Returns URL when probe connects.""" locker = create_locker(mock_tk, tmp_path) with ( patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), @@ -133,6 +140,7 @@ class TestScanForHttpServer: def test_returns_none_when_all_probes_refused( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Returns none when all probes refused.""" locker = create_locker(mock_tk, tmp_path) with ( patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), @@ -205,75 +213,3 @@ class TestVerifyPhoneWorkoutSyncStaleness: status, message = locker._verify_phone_workout() assert status == "stale" assert "2000-01-01" in message - - -class TestFetchHttpWorkout: - """Tests for _fetch_http_workout over the local HTTP server.""" - - def test_returns_none_when_scan_finds_nothing( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - with patch.object(locker, "_scan_for_http_server", return_value=None): - assert locker._fetch_http_workout() is None - - def test_returns_json_on_http_200( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - resp = MagicMock() - resp.status = 200 - resp.read.return_value = b'{"date": "2026-06-12", "exercises": ["a"]}' - conn = MagicMock() - conn.getresponse.return_value = resp - with ( - patch.object( - locker, - "_scan_for_http_server", - return_value="http://192.168.1.5:8765/workout", - ), - patch( - "screen_locker._phone_verification._HTTPConnection", - return_value=conn, - ), - ): - result = locker._fetch_http_workout() - assert result == {"date": "2026-06-12", "exercises": ["a"]} - - def test_returns_none_on_non_ok_status( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - resp = MagicMock() - resp.status = 404 - conn = MagicMock() - conn.getresponse.return_value = resp - with ( - patch.object( - locker, - "_scan_for_http_server", - return_value="http://192.168.1.5:8765/workout", - ), - patch( - "screen_locker._phone_verification._HTTPConnection", - return_value=conn, - ), - ): - assert locker._fetch_http_workout() is None - - def test_returns_none_on_connection_error( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - with ( - patch.object( - locker, - "_scan_for_http_server", - return_value="http://192.168.1.5:8765/workout", - ), - patch( - "screen_locker._phone_verification._HTTPConnection", - side_effect=OSError("unreachable"), - ), - ): - assert locker._fetch_http_workout() is None diff --git a/screen_locker/tests/test_phone_verification_part6.py b/screen_locker/tests/test_phone_verification_part6.py new file mode 100644 index 0000000..0b547cd --- /dev/null +++ b/screen_locker/tests/test_phone_verification_part6.py @@ -0,0 +1,113 @@ +"""Tests for phone verification coverage gaps (part 2).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestTryWirelessReconnect: + """Tests for _try_wireless_reconnect method.""" + + def test_returns_false_when_no_prefix( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when subnet prefix can't be determined.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object(locker, "_get_local_subnet_prefix", return_value=None): + result = locker._try_wireless_reconnect() + assert result is False + + def test_returns_true_when_probe_succeeds( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns True when a probe finds the phone.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), + patch.object(locker, "_try_adb_connect", return_value=True), + patch.object(locker, "_has_adb_device", return_value=True), + patch( + "screen_locker._phone_verification.socket.create_connection", + ) as mock_conn, + ): + mock_sock = MagicMock() + mock_sock.__enter__ = MagicMock(return_value=mock_sock) + mock_sock.__exit__ = MagicMock(return_value=False) + mock_conn.return_value = mock_sock + result = locker._try_wireless_reconnect() + assert result is True + + def test_returns_false_when_no_probe_succeeds( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when no probe finds the phone.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), + patch( + "screen_locker._phone_verification.socket.create_connection", + side_effect=OSError("refused"), + ), + ): + result = locker._try_wireless_reconnect() + assert result is False + + def test_probe_connect_succeeds_but_no_device( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test probe passes socket but adb_connect succeeds without device.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), + patch.object(locker, "_try_adb_connect", return_value=True), + patch.object(locker, "_has_adb_device", return_value=False), + patch( + "screen_locker._phone_verification.socket.create_connection", + ) as mock_conn, + ): + mock_sock = MagicMock() + mock_sock.__enter__ = MagicMock(return_value=mock_sock) + mock_sock.__exit__ = MagicMock(return_value=False) + mock_conn.return_value = mock_sock + result = locker._try_wireless_reconnect() + assert result is False + + def test_probe_adb_connect_fails( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test probe where socket connects but adb connect fails.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), + patch.object(locker, "_try_adb_connect", return_value=False), + patch( + "screen_locker._phone_verification.socket.create_connection", + ) as mock_conn, + ): + mock_sock = MagicMock() + mock_sock.__enter__ = MagicMock(return_value=mock_sock) + mock_sock.__exit__ = MagicMock(return_value=False) + mock_conn.return_value = mock_sock + result = locker._try_wireless_reconnect() + assert result is False diff --git a/screen_locker/tests/test_phone_verification_part7.py b/screen_locker/tests/test_phone_verification_part7.py new file mode 100644 index 0000000..e16d58c --- /dev/null +++ b/screen_locker/tests/test_phone_verification_part7.py @@ -0,0 +1,93 @@ +"""Tests for JSON workout validation and HTTP fallback (part 4). + +Replaces the obsolete StrongLifts-DB-based ``test_phone_check_unlock.py``. +Covers ``_validate_json_data`` (all status branches) and the HTTP fallback +``_scan_for_http_server`` / ``_fetch_http_workout`` used when ADB is +unavailable. Network is fully mocked — no test touches a real socket. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestFetchHttpWorkout: + """Tests for _fetch_http_workout over the local HTTP server.""" + + def test_returns_none_when_scan_finds_nothing( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Returns none when scan finds nothing.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object(locker, "_scan_for_http_server", return_value=None): + assert locker._fetch_http_workout() is None + + def test_returns_json_on_http_200( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Returns JSON on HTTP 200.""" + locker = create_locker(mock_tk, tmp_path) + resp = MagicMock() + resp.status = 200 + resp.read.return_value = b'{"date": "2026-06-12", "exercises": ["a"]}' + conn = MagicMock() + conn.getresponse.return_value = resp + with ( + patch.object( + locker, + "_scan_for_http_server", + return_value="http://192.168.1.5:8765/workout", + ), + patch( + "screen_locker._phone_verification._HTTPConnection", + return_value=conn, + ), + ): + result = locker._fetch_http_workout() + assert result == {"date": "2026-06-12", "exercises": ["a"]} + + def test_returns_none_on_non_ok_status( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Returns none on non ok status.""" + locker = create_locker(mock_tk, tmp_path) + resp = MagicMock() + resp.status = 404 + conn = MagicMock() + conn.getresponse.return_value = resp + with ( + patch.object( + locker, + "_scan_for_http_server", + return_value="http://192.168.1.5:8765/workout", + ), + patch( + "screen_locker._phone_verification._HTTPConnection", + return_value=conn, + ), + ): + assert locker._fetch_http_workout() is None + + def test_returns_none_on_connection_error( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Returns none on connection error.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object( + locker, + "_scan_for_http_server", + return_value="http://192.168.1.5:8765/workout", + ), + patch( + "screen_locker._phone_verification._HTTPConnection", + side_effect=OSError("unreachable"), + ), + ): + assert locker._fetch_http_workout() is None diff --git a/screen_locker/tests/test_shutdown_part2.py b/screen_locker/tests/test_shutdown_part2.py index fc41f83..e54644d 100644 --- a/screen_locker/tests/test_shutdown_part2.py +++ b/screen_locker/tests/test_shutdown_part2.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -212,74 +211,3 @@ class TestAdjustShutdownTimeLater: side_effect=OSError("fail"), ): assert locker._adjust_shutdown_time_later() is False - - -class TestSickModeUsedToday: - """Tests for _sick_mode_used_today method.""" - - def test_returns_false_when_no_file( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False when state file doesn't exist.""" - locker = create_locker(mock_tk, tmp_path) - mock_file = MagicMock() - mock_file.exists.return_value = False - with patch( - "screen_locker._shutdown.SICK_DAY_STATE_FILE", - mock_file, - ): - assert locker._sick_mode_used_today() is False - - def test_returns_true_when_used_today( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns True when state matches today.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - with patch( - "screen_locker._shutdown.SICK_DAY_STATE_FILE", - state_file, - ): - from datetime import datetime, timezone - - today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - state_file.write_text(json.dumps({"date": today})) - assert locker._sick_mode_used_today() is True - - def test_returns_false_when_different_date( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False when state is from different date.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - with patch( - "screen_locker._shutdown.SICK_DAY_STATE_FILE", - state_file, - ): - state_file.write_text(json.dumps({"date": "2020-01-01"})) - assert locker._sick_mode_used_today() is False - - def test_returns_false_on_json_error( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False on JSONDecodeError.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - with patch( - "screen_locker._shutdown.SICK_DAY_STATE_FILE", - state_file, - ): - state_file.write_text("not json{{{") - assert locker._sick_mode_used_today() is False diff --git a/screen_locker/tests/test_shutdown_part5.py b/screen_locker/tests/test_shutdown_part5.py new file mode 100644 index 0000000..36c1982 --- /dev/null +++ b/screen_locker/tests/test_shutdown_part5.py @@ -0,0 +1,83 @@ +"""Tests for shutdown schedule adjustment coverage gaps (part 2).""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestSickModeUsedToday: + """Tests for _sick_mode_used_today method.""" + + def test_returns_false_when_no_file( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when state file doesn't exist.""" + locker = create_locker(mock_tk, tmp_path) + mock_file = MagicMock() + mock_file.exists.return_value = False + with patch( + "screen_locker._shutdown.SICK_DAY_STATE_FILE", + mock_file, + ): + assert locker._sick_mode_used_today() is False + + def test_returns_true_when_used_today( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns True when state matches today.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + with patch( + "screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + from datetime import datetime, timezone + + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + state_file.write_text(json.dumps({"date": today})) + assert locker._sick_mode_used_today() is True + + def test_returns_false_when_different_date( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when state is from different date.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + with patch( + "screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + state_file.write_text(json.dumps({"date": "2020-01-01"})) + assert locker._sick_mode_used_today() is False + + def test_returns_false_on_json_error( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False on JSONDecodeError.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + with patch( + "screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + state_file.write_text("not json{{{") + assert locker._sick_mode_used_today() is False diff --git a/screen_locker/tests/test_sick_features.py b/screen_locker/tests/test_sick_features.py index 22c283e..786156f 100644 --- a/screen_locker/tests/test_sick_features.py +++ b/screen_locker/tests/test_sick_features.py @@ -25,6 +25,7 @@ class TestShowRetryAndSickBudget: def test_shows_sick_button_when_budget_available( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Shows sick button when budget available.""" locker = create_locker(mock_tk, tmp_path) with patch.object(_sick_tracker, "load_history", return_value=SickHistory()): locker._show_retry_and_sick("nope") @@ -40,6 +41,7 @@ class TestShowRetryAndSickBudget: def test_hides_sick_button_when_budget_exhausted( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Hides sick button when budget exhausted.""" locker = create_locker(mock_tk, tmp_path) full = SickHistory(sick_days=["2026-05-09"] * 99) with ( @@ -59,6 +61,7 @@ class TestProceedToSickCountdownLoadsHistory: def test_loads_history_when_cache_missing( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Loads history when cache missing.""" locker = create_locker(mock_tk, tmp_path) object.__setattr__(locker, "clear_container", MagicMock()) object.__setattr__( @@ -83,6 +86,7 @@ class TestFinalizeSickDay: def test_marks_commitment_broken_and_writes_debt( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Marks commitment broken and writes debt.""" locker = create_locker(mock_tk, tmp_path) locker.workout_data = {} history = SickHistory(commitments={"2026-05-10": True}) @@ -101,6 +105,7 @@ class TestFinalizeSickDay: def test_loads_history_when_cache_missing( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Loads history when cache missing.""" locker = create_locker(mock_tk, tmp_path) locker.workout_data = {} object.__setattr__(locker, "unlock_screen", MagicMock()) @@ -126,6 +131,7 @@ class TestClearDebtOnVerifiedWorkout: def test_returns_none_when_not_phone_verified( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Returns none when not phone verified.""" locker = create_locker(mock_tk, tmp_path) locker.workout_data = {"type": "sick_day"} assert locker._clear_debt_on_verified_workout() is None @@ -133,6 +139,7 @@ class TestClearDebtOnVerifiedWorkout: def test_returns_zero_when_no_debt( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Returns zero when no debt.""" locker = create_locker(mock_tk, tmp_path) locker.workout_data = {"type": "phone_verified"} with patch.object( @@ -143,6 +150,7 @@ class TestClearDebtOnVerifiedWorkout: def test_decrements_when_debt_positive( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Decrements when debt positive.""" locker = create_locker(mock_tk, tmp_path) locker.workout_data = {"type": "phone_verified"} history = SickHistory(debt=2) @@ -160,6 +168,7 @@ class TestUnlockScreenCommitmentPrompt: def test_phone_verified_schedules_commitment_prompt( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Phone verified schedules commitment prompt.""" locker = create_locker(mock_tk, tmp_path) locker.workout_data = {"type": "phone_verified"} locker.log_file = tmp_path / "log.json" @@ -182,6 +191,7 @@ class TestUnlockScreenCommitmentPrompt: def test_non_verified_schedules_close_directly( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Non verified schedules close directly.""" locker = create_locker(mock_tk, tmp_path) locker.workout_data = {"type": "sick_day"} locker.log_file = tmp_path / "log.json" @@ -203,6 +213,7 @@ class TestUnlockScreenCommitmentPrompt: def test_renders_debt_label_when_positive( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Renders debt label when positive.""" locker = create_locker(mock_tk, tmp_path) locker.workout_data = {"type": "phone_verified"} locker.log_file = tmp_path / "log.json" @@ -226,76 +237,3 @@ class TestUnlockScreenCommitmentPrompt: # --------------------------------------------------------------------------- # _sick_dialog.py — UI mixin # --------------------------------------------------------------------------- - - -class TestShowSickJustification: - """Tests for the structured sick justification dialog.""" - - def test_renders_form_without_commitment( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - with patch.object(_sick_tracker, "load_history", return_value=SickHistory()): - locker._show_sick_justification() - assert locker._sick_history_cache.sick_days == [] - assert hasattr(locker, "_sick_submit_button") - # Submit button starts enabled (no commitment). - # config(state="disabled") only called for commitment path. - for call in locker._sick_submit_button.first.configure.call_args_list: - assert call.kwargs.get("state") != "disabled" - - def test_renders_form_with_commitment_disables_submit( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - history = SickHistory(commitments={"2026-05-10": True}) - with ( - patch.object(_sick_tracker, "load_history", return_value=history), - patch.object(_sick_tracker, "had_commitment_for_today", return_value=True), - ): - locker._show_sick_justification() - # Submit button was disabled and forced-delay started. - states = [ - call.kwargs.get("state") - for call in locker._sick_submit_button.first.configure.call_args_list - ] - assert "disabled" in states - - def test_renders_recent_history_when_present( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - history = SickHistory( - justifications=[ - {"date": "2026-05-01", "symptom": "fever", "severity": 7}, - ], - ) - with patch.object(_sick_tracker, "load_history", return_value=history): - locker._show_sick_justification() - labels = [call.kwargs.get("text", "") for call in mock_tk.Label.call_args_list] - assert any("Recent sick days" in t for t in labels) - - -class TestUpdateCommitmentForcedDelay: - """Tests for _update_commitment_forced_delay.""" - - def test_ticks_down( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - locker._sick_submit_button = MagicMock() - locker._commitment_forced_remaining = 3 - locker._update_commitment_forced_delay() - assert locker._commitment_forced_remaining == 2 - locker.root.after.assert_called() - - def test_enables_when_done( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - locker._sick_submit_button = MagicMock() - locker._commitment_forced_remaining = 0 - locker._update_commitment_forced_delay() - locker._sick_submit_button.config.assert_called_with( - text="SUBMIT", state="normal" - ) diff --git a/screen_locker/tests/test_sick_features_part3.py b/screen_locker/tests/test_sick_features_part3.py new file mode 100644 index 0000000..e3dcb29 --- /dev/null +++ b/screen_locker/tests/test_sick_features_part3.py @@ -0,0 +1,92 @@ +"""Tests for sick-budget UI integration, finalize, debt-clear, and dialogs.""" +# pylint: disable=protected-access + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker import _sick_tracker +from screen_locker._sick_tracker import SickHistory +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestShowSickJustification: + """Tests for the structured sick justification dialog.""" + + def test_renders_form_without_commitment( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Renders form without commitment.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object(_sick_tracker, "load_history", return_value=SickHistory()): + locker._show_sick_justification() + assert locker._sick_history_cache.sick_days == [] + assert hasattr(locker, "_sick_submit_button") + # Submit button starts enabled (no commitment). + # config(state="disabled") only called for commitment path. + for call in locker._sick_submit_button.first.configure.call_args_list: + assert call.kwargs.get("state") != "disabled" + + def test_renders_form_with_commitment_disables_submit( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Renders form with commitment disables submit.""" + locker = create_locker(mock_tk, tmp_path) + history = SickHistory(commitments={"2026-05-10": True}) + with ( + patch.object(_sick_tracker, "load_history", return_value=history), + patch.object(_sick_tracker, "had_commitment_for_today", return_value=True), + ): + locker._show_sick_justification() + # Submit button was disabled and forced-delay started. + states = [ + call.kwargs.get("state") + for call in locker._sick_submit_button.first.configure.call_args_list + ] + assert "disabled" in states + + def test_renders_recent_history_when_present( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Renders recent history when present.""" + locker = create_locker(mock_tk, tmp_path) + history = SickHistory( + justifications=[ + {"date": "2026-05-01", "symptom": "fever", "severity": 7}, + ], + ) + with patch.object(_sick_tracker, "load_history", return_value=history): + locker._show_sick_justification() + labels = [call.kwargs.get("text", "") for call in mock_tk.Label.call_args_list] + assert any("Recent sick days" in t for t in labels) + + +class TestUpdateCommitmentForcedDelay: + """Tests for _update_commitment_forced_delay.""" + + def test_ticks_down( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Ticks down.""" + locker = create_locker(mock_tk, tmp_path) + locker._sick_submit_button = MagicMock() + locker._commitment_forced_remaining = 3 + locker._update_commitment_forced_delay() + assert locker._commitment_forced_remaining == 2 + locker.root.after.assert_called() + + def test_enables_when_done( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Enables when done.""" + locker = create_locker(mock_tk, tmp_path) + locker._sick_submit_button = MagicMock() + locker._commitment_forced_remaining = 0 + locker._update_commitment_forced_delay() + locker._sick_submit_button.config.assert_called_with( + text="SUBMIT", state="normal" + ) diff --git a/screen_locker/tests/test_status.py b/screen_locker/tests/test_status.py index 8b46809..12f3937 100644 --- a/screen_locker/tests/test_status.py +++ b/screen_locker/tests/test_status.py @@ -242,48 +242,3 @@ class TestRunStatusWorkoutLog: run_status(locker) out = capsys.readouterr().out assert "heat_skip" in out - - -class TestWeeklyTotalUsesPerWorkoutRule: - """The final summary must count each verified workout, not days-with-any.""" - - def test_multiple_same_day_workouts_all_count( - self, tmp_path: Path, capsys: pytest.CaptureFixture - ) -> None: - """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. Also covers manual workouts counting - individually (no once-per-day collapse), same as verified ones. - """ - from datetime import datetime, timezone - - today = datetime.now(tz=timezone.utc).astimezone().date().isoformat() - log_file = tmp_path / "log.json" - log_file.write_text( - json.dumps( - { - today: [ - {"workout_data": {"type": "manual_workout", "source": "gym"}}, - {"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"}}, - ] - } - ) - ) - eb_file = tmp_path / "eb.json" - # n_filled=0 so the summary is derived purely from before_count. - locker = _make_locker(log_file, n_filled=0) - with ( - 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("sys.exit"), - ): - run_status(locker) - out = capsys.readouterr().out - assert "Weekly minimum met exactly (5/5)." in out diff --git a/screen_locker/tests/test_status_part3.py b/screen_locker/tests/test_status_part3.py new file mode 100644 index 0000000..c8d5f2f --- /dev/null +++ b/screen_locker/tests/test_status_part3.py @@ -0,0 +1,63 @@ +"""Tests for run_status's weekly-total rule. + +Split out of test_status.py for the 250-line cap. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from screen_locker._status import run_status +from screen_locker.tests.conftest import _make_locker + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +class TestWeeklyTotalUsesPerWorkoutRule: + """The final summary must count each verified workout, not days-with-any.""" + + def test_multiple_same_day_workouts_all_count( + self, tmp_path: Path, capsys: pytest.CaptureFixture + ) -> None: + """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. Also covers manual workouts counting + individually (no once-per-day collapse), same as verified ones. + """ + from datetime import datetime, timezone + + today = datetime.now(tz=timezone.utc).astimezone().date().isoformat() + log_file = tmp_path / "log.json" + log_file.write_text( + json.dumps( + { + today: [ + {"workout_data": {"type": "manual_workout", "source": "gym"}}, + {"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"}}, + ] + } + ) + ) + eb_file = tmp_path / "eb.json" + # n_filled=0 so the summary is derived purely from before_count. + locker = _make_locker(log_file, n_filled=0) + with ( + 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("sys.exit"), + ): + run_status(locker) + out = capsys.readouterr().out + assert "Weekly minimum met exactly (5/5)." in out diff --git a/screen_locker/tests/test_status_view.py b/screen_locker/tests/test_status_view.py index 1c0d300..dc303f8 100644 --- a/screen_locker/tests/test_status_view.py +++ b/screen_locker/tests/test_status_view.py @@ -7,7 +7,6 @@ from unittest.mock import MagicMock, patch from screen_locker._compliance_state import AutoUpgradeOpportunity from screen_locker._status_view_verify import ( PhoneCheckOutcome, - _backfill_week_and_apply_bonus, _verify_phone_then_runnerup, ) from screen_locker.tests._status_view_helpers import ( @@ -23,7 +22,10 @@ from screen_locker.tests._status_view_helpers import ( class TestSectionToday: + """SectionToday.""" + def test_counted_shows_checkmark_and_entry(self, mock_tk: MagicMock) -> None: + """Counted shows checkmark and entry.""" snap = _snapshot( today=_day( entry_types=("phone_verified",), @@ -38,16 +40,19 @@ class TestSectionToday: assert any(t == "gym" for t in texts) def test_sick_day_shows_sick_mark(self, mock_tk: MagicMock) -> None: + """Sick day shows sick mark.""" snap = _snapshot(today=_day(entry_types=(), is_sick_day=True)) _make_window(mock_tk, snap) assert any("😷" in t and "sick day" in t for t in _texts(mock_tk)) def test_no_entry_shows_dash(self, mock_tk: MagicMock) -> None: + """No entry shows dash.""" snap = _snapshot(today=_day(entry_types=(), is_sick_day=False)) _make_window(mock_tk, snap) assert any("no entry yet" in t for t in _texts(mock_tk)) def test_empty_source_adds_no_extra_line(self, mock_tk: MagicMock) -> None: + """Empty source adds no extra line.""" snap = _snapshot( today=_day( entry_types=("phone_verified",), @@ -63,19 +68,24 @@ class TestSectionToday: class TestSectionWeek: + """SectionWeek.""" + def test_shows_remaining_message_when_under_minimum( self, mock_tk: MagicMock ) -> None: + """Shows remaining message when under minimum.""" snap = _snapshot(week=_week(counted_count=2, remaining=2, extra=0)) _make_window(mock_tk, snap) assert any("Need 2 more this week." in t for t in _texts(mock_tk)) def test_shows_extra_message_when_over_minimum(self, mock_tk: MagicMock) -> None: + """Shows extra message when over minimum.""" snap = _snapshot(week=_week(counted_count=5, remaining=0, extra=1)) _make_window(mock_tk, snap) assert any("above the weekly minimum" in t for t in _texts(mock_tk)) def test_shows_neither_message_at_exact_minimum(self, mock_tk: MagicMock) -> None: + """Shows neither message at exact minimum.""" snap = _snapshot(week=_week(counted_count=4, remaining=0, extra=0)) _make_window(mock_tk, snap) texts = _texts(mock_tk) @@ -83,6 +93,7 @@ class TestSectionWeek: assert not any("above the weekly minimum" in t for t in texts) def test_renders_a_line_per_day(self, mock_tk: MagicMock) -> None: + """Renders a line per day.""" days = ( _day( date="2024-01-01", @@ -103,9 +114,12 @@ class TestSectionWeek: class TestSectionLockExplanation: + """SectionLockExplanation.""" + def test_fired_with_auto_upgrade_and_heat_skip_pending( self, mock_tk: MagicMock ) -> None: + """Fired with auto upgrade and heat skip pending.""" snap = _snapshot( lock_explanation=_lock_explanation( fired=True, @@ -122,6 +136,7 @@ class TestSectionLockExplanation: assert any("Pending auto-upgrade: will try phone" in t for t in texts) def test_not_fired_no_auto_upgrade(self, mock_tk: MagicMock) -> None: + """Not fired no auto upgrade.""" snap = _snapshot( lock_explanation=_lock_explanation(fired=False, reason="Skipped.") ) @@ -143,7 +158,10 @@ class TestSectionLockExplanation: class TestSectionSickBudget: + """SectionSickBudget.""" + def test_exhausted_uses_warning_color(self, mock_tk: MagicMock) -> None: + """Exhausted uses warning color.""" snap = _snapshot(sick_budget=_sick_budget(used_7d=1, exhausted=True)) window = _make_window(mock_tk, snap) calls = [ @@ -154,6 +172,7 @@ class TestSectionSickBudget: assert any(c.kwargs.get("fg") == window._colors.danger for c in calls) def test_not_exhausted_uses_normal_color(self, mock_tk: MagicMock) -> None: + """Not exhausted uses normal color.""" snap = _snapshot(sick_budget=_sick_budget(used_7d=0, exhausted=False)) window = _make_window(mock_tk, snap) calls = [ @@ -165,17 +184,22 @@ class TestSectionSickBudget: class TestSectionShutdown: + """SectionShutdown.""" + def test_tonight_present_shows_live_config(self, mock_tk: MagicMock) -> None: + """Tonight present shows live config.""" snap = _snapshot(shutdown=_shutdown(tonight=(22, 23, 5))) _make_window(mock_tk, snap) assert any("22:00" in t and "23:00" in t for t in _texts(mock_tk)) def test_tonight_absent_shows_unavailable_message(self, mock_tk: MagicMock) -> None: + """Tonight absent shows unavailable message.""" snap = _snapshot(shutdown=_shutdown(tonight=None)) _make_window(mock_tk, snap) assert any("Live shutdown config unavailable." in t for t in _texts(mock_tk)) def test_shows_rest_of_week_and_next_week_preview(self, mock_tk: MagicMock) -> None: + """Shows rest of week and next week preview.""" snap = _snapshot() _make_window(mock_tk, snap) texts = _texts(mock_tk) @@ -245,50 +269,3 @@ class TestVerifyPhoneThenRunnerup: result.week_fill_message == "Auto-filled 1 workout from earlier this week." ) verifier._adjust_shutdown_time_by.assert_not_called() - - -class TestBackfillWeekAndApplyBonus: - """The week-scan fallback: backfill unlogged days, apply the earned bonus.""" - - def test_nothing_filled_returns_none(self) -> None: - verifier = MagicMock() - verifier._scan_and_fill_week_runnerup.return_value = 0 - verifier._try_fill_stronglifts_for_week.return_value = 0 - - with patch( - "screen_locker._status_view_verify.count_weekly_workouts", - return_value=2, - ): - assert _backfill_week_and_apply_bonus(verifier) is None - verifier._adjust_shutdown_time_by.assert_not_called() - - def test_fill_at_or_below_minimum_earns_no_bonus(self) -> None: - """Filling up to (not past) the weekly minimum earns no shutdown push.""" - verifier = MagicMock() - verifier._scan_and_fill_week_runnerup.return_value = 1 - verifier._try_fill_stronglifts_for_week.return_value = 0 - - with patch( - "screen_locker._status_view_verify.count_weekly_workouts", - side_effect=[3, 4], - ): - message = _backfill_week_and_apply_bonus(verifier) - - assert message == "Auto-filled 1 workout from earlier this week." - verifier._adjust_shutdown_time_by.assert_not_called() - - def test_fill_past_minimum_applies_bonus(self) -> None: - verifier = MagicMock() - verifier._scan_and_fill_week_runnerup.return_value = 1 - verifier._try_fill_stronglifts_for_week.return_value = 1 - - with patch( - "screen_locker._status_view_verify.count_weekly_workouts", - side_effect=[4, 7], - ): - message = _backfill_week_and_apply_bonus(verifier) - - assert message == ( - "Auto-filled 2 workouts from earlier this week. +2h shutdown time." - ) - verifier._adjust_shutdown_time_by.assert_called_once_with(2) diff --git a/screen_locker/tests/test_status_view_part4.py b/screen_locker/tests/test_status_view_part4.py new file mode 100644 index 0000000..60ea477 --- /dev/null +++ b/screen_locker/tests/test_status_view_part4.py @@ -0,0 +1,58 @@ +"""Tests for the read-only Tkinter status window's rendering (StatusWindow.render).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from screen_locker._status_view_verify import ( + _backfill_week_and_apply_bonus, +) + + +class TestBackfillWeekAndApplyBonus: + """The week-scan fallback: backfill unlogged days, apply the earned bonus.""" + + def test_nothing_filled_returns_none(self) -> None: + """Nothing filled returns none.""" + verifier = MagicMock() + verifier._scan_and_fill_week_runnerup.return_value = 0 + verifier._try_fill_stronglifts_for_week.return_value = 0 + + with patch( + "screen_locker._status_view_verify.count_weekly_workouts", + return_value=2, + ): + assert _backfill_week_and_apply_bonus(verifier) is None + verifier._adjust_shutdown_time_by.assert_not_called() + + def test_fill_at_or_below_minimum_earns_no_bonus(self) -> None: + """Filling up to (not past) the weekly minimum earns no shutdown push.""" + verifier = MagicMock() + verifier._scan_and_fill_week_runnerup.return_value = 1 + verifier._try_fill_stronglifts_for_week.return_value = 0 + + with patch( + "screen_locker._status_view_verify.count_weekly_workouts", + side_effect=[3, 4], + ): + message = _backfill_week_and_apply_bonus(verifier) + + assert message == "Auto-filled 1 workout from earlier this week." + verifier._adjust_shutdown_time_by.assert_not_called() + + def test_fill_past_minimum_applies_bonus(self) -> None: + """Fill past minimum applies bonus.""" + verifier = MagicMock() + verifier._scan_and_fill_week_runnerup.return_value = 1 + verifier._try_fill_stronglifts_for_week.return_value = 1 + + with patch( + "screen_locker._status_view_verify.count_weekly_workouts", + side_effect=[4, 7], + ): + message = _backfill_week_and_apply_bonus(verifier) + + assert message == ( + "Auto-filled 2 workouts from earlier this week. +2h shutdown time." + ) + verifier._adjust_shutdown_time_by.assert_called_once_with(2) diff --git a/screen_locker/tests/test_weekly_check.py b/screen_locker/tests/test_weekly_check.py index 312cef9..cd6e855 100644 --- a/screen_locker/tests/test_weekly_check.py +++ b/screen_locker/tests/test_weekly_check.py @@ -10,9 +10,7 @@ from unittest.mock import patch from screen_locker._weekly_check import ( _RELAXED_WEEKDAYS, - WEEKLY_WORKOUT_MINIMUM, count_weekly_workouts, - has_weekly_minimum, is_relaxed_day, ) @@ -52,31 +50,42 @@ def _make_log(entries: dict[str, str], log_file: Path) -> Path: class TestIsRelaxedDay: + """Tue-Thu are optional; Fri-Mon are enforced lock days.""" + def test_monday_is_enforced(self) -> None: + """Monday is an enforced day.""" assert is_relaxed_day(today=_dt(0)) is False def test_tuesday_is_relaxed(self) -> None: + """Tuesday is relaxed.""" assert is_relaxed_day(today=_dt(1)) is True def test_wednesday_is_relaxed(self) -> None: + """Wednesday is relaxed.""" assert is_relaxed_day(today=_dt(2)) is True def test_thursday_is_relaxed(self) -> None: + """Thursday is relaxed.""" assert is_relaxed_day(today=_dt(3)) is True def test_friday_is_enforced(self) -> None: + """Friday is an enforced day.""" assert is_relaxed_day(today=_dt(4)) is False def test_saturday_is_enforced(self) -> None: + """Saturday is an enforced day.""" assert is_relaxed_day(today=_dt(5)) is False def test_sunday_is_enforced(self) -> None: + """Sunday is an enforced day.""" assert is_relaxed_day(today=_dt(6)) is False def test_relaxed_weekdays_constant_correct(self) -> None: + """The relaxed set is exactly Tue/Wed/Thu (weekday 1-3).""" assert frozenset({1, 2, 3}) == _RELAXED_WEEKDAYS def test_uses_local_time_by_default(self) -> None: + """Called with no argument it reads local time and still returns a bool.""" result = is_relaxed_day() assert isinstance(result, bool) @@ -87,50 +96,61 @@ class TestIsRelaxedDay: class TestCountWeeklyWorkouts: + """Counting verified workouts inside the current ISO week.""" + def test_no_log_file_returns_zero(self, tmp_path: Path) -> None: + """A missing log counts as zero rather than raising.""" log = tmp_path / "workout_log.json" assert count_weekly_workouts(log, today=_dt(4)) == 0 def test_corrupt_json_returns_zero(self, tmp_path: Path) -> None: + """Unparsable JSON counts as zero rather than raising.""" log = tmp_path / "workout_log.json" log.write_text("{not valid json}") assert count_weekly_workouts(log, today=_dt(4)) == 0 def test_oserror_returns_zero(self, tmp_path: Path) -> None: + """An unreadable log counts as zero rather than raising.""" log = tmp_path / "workout_log.json" log.write_text("{}") with patch("builtins.open", side_effect=OSError("no permission")): assert count_weekly_workouts(log, today=_dt(4)) == 0 def test_counts_phone_verified_in_current_week(self, tmp_path: Path) -> None: + """Two phone-verified days in the same week count as two.""" log = tmp_path / "workout_log.json" # Mon=2025-05-19, Tue=2025-05-20 both in same week; check on Fri=2025-05-23 _make_log({"2025-05-19": "phone_verified", "2025-05-20": "phone_verified"}, log) assert count_weekly_workouts(log, today=_dt(4)) == 2 def test_sick_day_not_counted(self, tmp_path: Path) -> None: + """A sick day is not a workout.""" log = tmp_path / "workout_log.json" _make_log({"2025-05-19": "sick_day"}, log) assert count_weekly_workouts(log, today=_dt(4)) == 0 def test_early_bird_not_counted(self, tmp_path: Path) -> None: + """An early-bird entry is not a workout.""" log = tmp_path / "workout_log.json" _make_log({"2025-05-19": "early_bird"}, log) assert count_weekly_workouts(log, today=_dt(4)) == 0 def test_previous_week_not_counted(self, tmp_path: Path) -> None: + """Last week's workouts do not count toward this week.""" log = tmp_path / "workout_log.json" # 2025-05-12 is the Monday of the previous week _make_log({"2025-05-12": "phone_verified"}, log) assert count_weekly_workouts(log, today=_dt(4)) == 0 def test_future_date_not_counted(self, tmp_path: Path) -> None: + """A date after today does not count, even in the same week.""" log = tmp_path / "workout_log.json" # 2025-05-24 is Saturday, checking on Friday 2025-05-23 _make_log({"2025-05-24": "phone_verified"}, log) assert count_weekly_workouts(log, today=_dt(4)) == 0 def test_invalid_date_key_skipped(self, tmp_path: Path) -> None: + """An unparsable date key is skipped, and valid siblings still count.""" log = tmp_path / "workout_log.json" data: dict[str, Any] = { "not-a-date": { @@ -146,12 +166,14 @@ class TestCountWeeklyWorkouts: assert count_weekly_workouts(log, today=_dt(4)) == 1 def test_non_dict_entry_skipped(self, tmp_path: Path) -> None: + """An entry that is not a dict is skipped rather than raising.""" log = tmp_path / "workout_log.json" data: dict[str, Any] = {"2025-05-19": "not-a-dict"} log.write_text(json.dumps(data)) assert count_weekly_workouts(log, today=_dt(4)) == 0 def test_counts_up_to_four(self, tmp_path: Path) -> None: + """Four workouts in one week count as four (no cap below the minimum).""" log = tmp_path / "workout_log.json" _make_log( { @@ -165,18 +187,21 @@ class TestCountWeeklyWorkouts: assert count_weekly_workouts(log, today=_dt(4)) == 4 def test_today_counts_if_this_week(self, tmp_path: Path) -> None: + """Today's own workout counts.""" log = tmp_path / "workout_log.json" # today is Friday 2025-05-23 _make_log({"2025-05-23": "phone_verified"}, log) assert count_weekly_workouts(log, today=_dt(4)) == 1 def test_monday_start_of_week_counted(self, tmp_path: Path) -> None: + """Monday is the first day of the week, so it counts when checked on Monday.""" log = tmp_path / "workout_log.json" _make_log({"2025-05-19": "phone_verified"}, log) # Checking on Monday itself (today=Mon) assert count_weekly_workouts(log, today=_dt(0)) == 1 def test_mixed_types_only_verified_counted(self, tmp_path: Path) -> None: + """Only verified workouts count; sick/early-bird entries are ignored.""" log = tmp_path / "workout_log.json" _make_log( { @@ -209,51 +234,3 @@ class TestCountWeeklyWorkouts: # --------------------------------------------------------------------------- # has_weekly_minimum # --------------------------------------------------------------------------- - - -class TestHasWeeklyMinimum: - def test_zero_workouts_is_false(self, tmp_path: Path) -> None: - log = tmp_path / "workout_log.json" - assert has_weekly_minimum(log, today=_dt(4)) is False - - def test_three_workouts_is_false(self, tmp_path: Path) -> None: - log = tmp_path / "workout_log.json" - _make_log( - { - "2025-05-19": "phone_verified", - "2025-05-20": "phone_verified", - "2025-05-21": "phone_verified", - }, - log, - ) - assert has_weekly_minimum(log, today=_dt(4)) is False - - def test_four_workouts_is_false(self, tmp_path: Path) -> None: - log = tmp_path / "workout_log.json" - _make_log( - { - "2025-05-19": "phone_verified", - "2025-05-20": "phone_verified", - "2025-05-21": "phone_verified", - "2025-05-22": "phone_verified", - }, - log, - ) - 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" - _make_log( - { - "2025-05-19": "phone_verified", - "2025-05-20": "phone_verified", - "2025-05-21": "phone_verified", - "2025-05-22": "phone_verified", - "2025-05-23": "phone_verified", - }, - log, - ) - assert has_weekly_minimum(log, today=_dt(4)) is True - - def test_weekly_workout_minimum_constant(self) -> None: - assert WEEKLY_WORKOUT_MINIMUM == 5 diff --git a/screen_locker/tests/test_weekly_check_part2.py b/screen_locker/tests/test_weekly_check_part2.py new file mode 100644 index 0000000..4c75119 --- /dev/null +++ b/screen_locker/tests/test_weekly_check_part2.py @@ -0,0 +1,73 @@ +"""Tests for _weekly_check.has_weekly_minimum. + +Split out of test_weekly_check.py for the 250-line cap; the shared _dt / +_make_log helpers stay in that module and are imported here. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from screen_locker._weekly_check import ( + WEEKLY_WORKOUT_MINIMUM, + has_weekly_minimum, +) +from screen_locker.tests.test_weekly_check import _dt, _make_log + +if TYPE_CHECKING: + from pathlib import Path + + +class TestHasWeeklyMinimum: + """has_weekly_minimum counts verified workouts against the weekly bar.""" + + def test_zero_workouts_is_false(self, tmp_path: Path) -> None: + """An empty log does not meet the weekly minimum.""" + log = tmp_path / "workout_log.json" + assert has_weekly_minimum(log, today=_dt(4)) is False + + def test_three_workouts_is_false(self, tmp_path: Path) -> None: + """Three workouts is short of the five-workout bar.""" + log = tmp_path / "workout_log.json" + _make_log( + { + "2025-05-19": "phone_verified", + "2025-05-20": "phone_verified", + "2025-05-21": "phone_verified", + }, + log, + ) + assert has_weekly_minimum(log, today=_dt(4)) is False + + def test_four_workouts_is_false(self, tmp_path: Path) -> None: + """Four workouts is still one short of the bar.""" + log = tmp_path / "workout_log.json" + _make_log( + { + "2025-05-19": "phone_verified", + "2025-05-20": "phone_verified", + "2025-05-21": "phone_verified", + "2025-05-22": "phone_verified", + }, + log, + ) + assert has_weekly_minimum(log, today=_dt(4)) is False + + def test_five_workouts_is_true(self, tmp_path: Path) -> None: + """Five workouts meets the bar exactly.""" + log = tmp_path / "workout_log.json" + _make_log( + { + "2025-05-19": "phone_verified", + "2025-05-20": "phone_verified", + "2025-05-21": "phone_verified", + "2025-05-22": "phone_verified", + "2025-05-23": "phone_verified", + }, + log, + ) + assert has_weekly_minimum(log, today=_dt(4)) is True + + def test_weekly_workout_minimum_constant(self) -> None: + """The bar is five workouts per ISO week.""" + assert WEEKLY_WORKOUT_MINIMUM == 5 diff --git a/screen_locker/tests/test_workout_sync.py b/screen_locker/tests/test_workout_sync.py index fda12ff..1eb7abd 100644 --- a/screen_locker/tests/test_workout_sync.py +++ b/screen_locker/tests/test_workout_sync.py @@ -8,16 +8,14 @@ and the cross-device merge. The backend selection (``sync_client`` / from __future__ import annotations import json -from unittest.mock import MagicMock -from crdt_sync import GitHubSyncError, Hlc, Record +from crdt_sync import Hlc, Record import pytest from screen_locker import _workout_sync from screen_locker.tests._workout_sync_fixtures import ( _manual_payload, _manual_record_dict, - _multi_device_client, _record_json, _session_payload, _session_record_dict, @@ -33,14 +31,19 @@ from screen_locker.tests._workout_sync_fixtures import ( class TestReadSyncToken: + """ReadSyncToken.""" + def test_returns_none_when_file_is_missing(self) -> None: + """Returns none when file is missing.""" assert _workout_sync.read_sync_token() is None def test_returns_none_when_file_is_empty(self) -> None: + """Returns none when file is empty.""" _workout_sync.SYNC_TOKEN_FILE.write_text(" \n") assert _workout_sync.read_sync_token() is None def test_returns_the_stripped_token(self) -> None: + """Returns the stripped token.""" _workout_sync.SYNC_TOKEN_FILE.write_text(" abc123 \n") assert _workout_sync.read_sync_token() == "abc123" @@ -49,12 +52,15 @@ class TestIsSessionPayload: """Sessions are identified by SHAPE (an ``exercises`` list), not ``kind``.""" def test_true_for_a_dict_with_an_exercises_list(self) -> None: + """True for a dict with an exercises list.""" assert _workout_sync._is_session_payload(_session_payload()) is True def test_true_even_with_an_empty_exercises_list(self) -> None: + """True even with an empty exercises list.""" assert _workout_sync._is_session_payload({"exercises": []}) is True def test_false_for_a_manual_workout(self) -> None: + """False for a manual workout.""" assert _workout_sync._is_session_payload(_manual_payload()) is False def test_false_for_verified_records_sharing_the_same_log(self) -> None: @@ -63,24 +69,33 @@ class TestIsSessionPayload: assert _workout_sync._is_session_payload({"kind": "phone_verified"}) is False def test_false_when_exercises_is_not_a_list(self) -> None: + """False when exercises is not a list.""" assert _workout_sync._is_session_payload({"exercises": "Squat"}) is False def test_false_for_a_non_dict_payload(self) -> None: + """False for a non dict payload.""" assert _workout_sync._is_session_payload("not-a-dict") is False assert _workout_sync._is_session_payload(None) is False class TestIsManualPayload: + """IsManualPayload.""" + def test_true_for_manual_kind(self) -> None: + """True for manual kind.""" assert _workout_sync._is_manual_payload({"kind": "manual_workout"}) is True def test_false_for_session_or_non_dict(self) -> None: + """False for session or non dict.""" assert _workout_sync._is_manual_payload(_session_payload()) is False assert _workout_sync._is_manual_payload("not-a-dict") is False class TestSessionRecords: + """SessionRecords.""" + def test_returns_only_session_records(self) -> None: + """Returns only session records.""" log = { "s": _session_record_dict("session:1", _session_payload()), "m": _manual_record_dict("manual:1", _manual_payload()), @@ -90,6 +105,7 @@ class TestSessionRecords: assert list(result) == ["session:1"] def test_skips_records_without_a_payload_field(self) -> None: + """Skips records without a payload field.""" no_payload = Record( id="a", fields={"other": (1, Hlc(wall_time_ms=1, counter=0, node_id="phone"))}, @@ -98,21 +114,27 @@ class TestSessionRecords: assert _workout_sync._session_records(json.dumps(log)) == {} def test_raises_type_error_when_top_level_is_not_an_object(self) -> None: + """Raises type error when top level is not an object.""" with pytest.raises(TypeError, match="not a JSON object"): _workout_sync._session_records(json.dumps([1, 2, 3])) def test_raises_value_error_for_invalid_json(self) -> None: + """Raises value error for invalid JSON.""" with pytest.raises(ValueError, match="Expecting property name"): _workout_sync._session_records("{not valid json") def test_raises_key_error_for_a_malformed_record(self) -> None: + """Raises key error for a malformed record.""" log = {"a": {"id": "a"}} # missing "fields" with pytest.raises(KeyError): _workout_sync._session_records(json.dumps(log)) class TestManualRecords: + """ManualRecords.""" + def test_returns_only_manual_records(self) -> None: + """Returns only manual records.""" log = { "s": _record_json(_session_payload()), "m": _manual_record_dict("manual:1", _manual_payload()), @@ -121,6 +143,7 @@ class TestManualRecords: assert list(result) == ["manual:1"] def test_skips_records_without_a_payload_field(self) -> None: + """Skips records without a payload field.""" no_payload = Record( id="a", fields={"other": (1, Hlc(wall_time_ms=1, counter=0, node_id="phone"))}, @@ -129,150 +152,6 @@ class TestManualRecords: assert _workout_sync._manual_records(json.dumps(log)) == {} def test_raises_type_error_when_top_level_is_not_an_object(self) -> None: + """Raises type error when top level is not an object.""" with pytest.raises(TypeError, match="not a JSON object"): _workout_sync._manual_records(json.dumps([1, 2, 3])) - - -class TestMergeDeviceRecords: - """The walk over EVERY ``devices//log.json``, not one hardcoded path.""" - - def test_merges_records_across_every_device(self) -> None: - client = _multi_device_client( - { - "phone": json.dumps( - {"a": _session_record_dict("session:a", _session_payload())} - ), - "pc": json.dumps( - {"b": _session_record_dict("session:b", _session_payload())} - ), - } - ) - merged = _workout_sync._merge_device_records( - client, _workout_sync._session_records, "sessions" - ) - assert sorted(merged) == ["session:a", "session:b"] - - def test_dedups_the_same_id_keeping_the_highest_clock(self) -> None: - client = _multi_device_client( - { - "phone": json.dumps( - { - "a": _session_record_dict( - "session:a", - _session_payload(note="OLD"), - wall_time_ms=100, - ) - } - ), - "pc": json.dumps( - { - "a": _session_record_dict( - "session:a", - _session_payload(note="NEW"), - wall_time_ms=200, - ) - } - ), - } - ) - merged = _workout_sync._merge_device_records( - client, _workout_sync._session_records, "sessions" - ) - assert len(merged) == 1 - assert merged["session:a"][0]["note"] == "NEW" - - def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None: - client = _multi_device_client( - { - "phone": json.dumps( - { - "a": _session_record_dict( - "session:a", - _session_payload(note="NEW"), - wall_time_ms=200, - ) - } - ), - "pc": json.dumps( - { - "a": _session_record_dict( - "session:a", - _session_payload(note="OLD"), - wall_time_ms=100, - ) - } - ), - } - ) - merged = _workout_sync._merge_device_records( - client, _workout_sync._session_records, "sessions" - ) - assert merged["session:a"][0]["note"] == "NEW" - - def test_skips_a_corrupt_device_log(self) -> None: - client = _multi_device_client( - { - "good": json.dumps( - {"a": _session_record_dict("session:a", _session_payload())} - ), - "corrupt": "{not json", - } - ) - merged = _workout_sync._merge_device_records( - client, _workout_sync._session_records, "sessions" - ) - assert list(merged) == ["session:a"] - - def test_skips_an_unreachable_device(self) -> None: - client = _multi_device_client( - { - "good": json.dumps( - {"a": _session_record_dict("session:a", _session_payload())} - ), - "gone": GitHubSyncError("404"), - "empty": None, - } - ) - merged = _workout_sync._merge_device_records( - client, _workout_sync._session_records, "sessions" - ) - assert list(merged) == ["session:a"] - - def test_returns_empty_on_a_listing_failure_when_not_strict(self) -> None: - client = MagicMock() - client.list_directory.side_effect = GitHubSyncError("offline") - merged = _workout_sync._merge_device_records( - client, _workout_sync._session_records, "sessions" - ) - assert merged == {} - - def test_reraises_on_a_listing_failure_when_strict(self) -> None: - """Callers separating "nothing to sync" from "sync broke" need the raise.""" - client = MagicMock() - client.list_directory.side_effect = GitHubSyncError("offline") - with pytest.raises(GitHubSyncError, match="offline"): - _workout_sync._merge_device_records( - client, _workout_sync._session_records, "sessions", strict=True - ) - - def test_warns_about_a_listing_failure( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """A total pull failure must never be silent.""" - client = MagicMock() - client.list_directory.side_effect = GitHubSyncError("offline") - with caplog.at_level("WARNING"): - _workout_sync._merge_device_records( - client, _workout_sync._session_records, "sessions" - ) - assert "offline" in caplog.text - - def test_warns_about_a_skipped_device( - self, caplog: pytest.LogCaptureFixture - ) -> None: - client = _multi_device_client({"gone": GitHubSyncError("404")}) - with caplog.at_level("WARNING"): - _workout_sync._merge_device_records( - client, _workout_sync._session_records, "sessions" - ) - assert "SKIPPING that device" in caplog.text diff --git a/screen_locker/tests/test_workout_sync_part4.py b/screen_locker/tests/test_workout_sync_part4.py new file mode 100644 index 0000000..729428b --- /dev/null +++ b/screen_locker/tests/test_workout_sync_part4.py @@ -0,0 +1,173 @@ +"""Tests for the phone-workout sync pull (crdt-sync transport). + +Covers the token read, the payload predicates, the per-log record extractors +and the cross-device merge. The backend selection (``sync_client`` / +``remote_client``) and the two public pulls live in ``_part2``. +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +from crdt_sync import GitHubSyncError +import pytest + +from screen_locker import _workout_sync +from screen_locker.tests._workout_sync_fixtures import ( + _multi_device_client, + _session_payload, + _session_record_dict, +) + + +class TestMergeDeviceRecords: + """The walk over EVERY ``devices//log.json``, not one hardcoded path.""" + + def test_merges_records_across_every_device(self) -> None: + """Merges records across every device.""" + client = _multi_device_client( + { + "phone": json.dumps( + {"a": _session_record_dict("session:a", _session_payload())} + ), + "pc": json.dumps( + {"b": _session_record_dict("session:b", _session_payload())} + ), + } + ) + merged = _workout_sync._merge_device_records( + client, _workout_sync._session_records, "sessions" + ) + assert sorted(merged) == ["session:a", "session:b"] + + def test_dedups_the_same_id_keeping_the_highest_clock(self) -> None: + """Dedups the same ID keeping the highest clock.""" + client = _multi_device_client( + { + "phone": json.dumps( + { + "a": _session_record_dict( + "session:a", + _session_payload(note="OLD"), + wall_time_ms=100, + ) + } + ), + "pc": json.dumps( + { + "a": _session_record_dict( + "session:a", + _session_payload(note="NEW"), + wall_time_ms=200, + ) + } + ), + } + ) + merged = _workout_sync._merge_device_records( + client, _workout_sync._session_records, "sessions" + ) + assert len(merged) == 1 + assert merged["session:a"][0]["note"] == "NEW" + + def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None: + """Ignores a lower clock duplicate seen later.""" + client = _multi_device_client( + { + "phone": json.dumps( + { + "a": _session_record_dict( + "session:a", + _session_payload(note="NEW"), + wall_time_ms=200, + ) + } + ), + "pc": json.dumps( + { + "a": _session_record_dict( + "session:a", + _session_payload(note="OLD"), + wall_time_ms=100, + ) + } + ), + } + ) + merged = _workout_sync._merge_device_records( + client, _workout_sync._session_records, "sessions" + ) + assert merged["session:a"][0]["note"] == "NEW" + + def test_skips_a_corrupt_device_log(self) -> None: + """Skips a corrupt device log.""" + client = _multi_device_client( + { + "good": json.dumps( + {"a": _session_record_dict("session:a", _session_payload())} + ), + "corrupt": "{not json", + } + ) + merged = _workout_sync._merge_device_records( + client, _workout_sync._session_records, "sessions" + ) + assert list(merged) == ["session:a"] + + def test_skips_an_unreachable_device(self) -> None: + """Skips an unreachable device.""" + client = _multi_device_client( + { + "good": json.dumps( + {"a": _session_record_dict("session:a", _session_payload())} + ), + "gone": GitHubSyncError("404"), + "empty": None, + } + ) + merged = _workout_sync._merge_device_records( + client, _workout_sync._session_records, "sessions" + ) + assert list(merged) == ["session:a"] + + def test_returns_empty_on_a_listing_failure_when_not_strict(self) -> None: + """Returns empty on a listing failure when not strict.""" + client = MagicMock() + client.list_directory.side_effect = GitHubSyncError("offline") + merged = _workout_sync._merge_device_records( + client, _workout_sync._session_records, "sessions" + ) + assert merged == {} + + def test_reraises_on_a_listing_failure_when_strict(self) -> None: + """Callers separating "nothing to sync" from "sync broke" need the raise.""" + client = MagicMock() + client.list_directory.side_effect = GitHubSyncError("offline") + with pytest.raises(GitHubSyncError, match="offline"): + _workout_sync._merge_device_records( + client, _workout_sync._session_records, "sessions", strict=True + ) + + def test_warns_about_a_listing_failure( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """A total pull failure must never be silent.""" + client = MagicMock() + client.list_directory.side_effect = GitHubSyncError("offline") + with caplog.at_level("WARNING"): + _workout_sync._merge_device_records( + client, _workout_sync._session_records, "sessions" + ) + assert "offline" in caplog.text + + def test_warns_about_a_skipped_device( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Warns about a skipped device.""" + client = _multi_device_client({"gone": GitHubSyncError("404")}) + with caplog.at_level("WARNING"): + _workout_sync._merge_device_records( + client, _workout_sync._session_records, "sessions" + ) + assert "SKIPPING that device" in caplog.text