From dd8296fd3f3b7869d404811ab465fa674b46e491 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 19:34:59 +0200 Subject: [PATCH] Split 12 more test modules under the 250-line cap Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y --- screen_locker/tests/test_manual_workout.py | 208 +--------- .../tests/test_manual_workout_part2.py | 238 +++++++++++ .../tests/test_runnerup_verification_part1.py | 207 +--------- .../test_runnerup_verification_part10.py | 295 ++++++++++++++ .../tests/test_runnerup_verification_part3.py | 323 +-------------- .../tests/test_runnerup_verification_part7.py | 157 ++++++++ .../tests/test_runnerup_verification_part8.py | 213 ++++++++++ .../tests/test_runnerup_verification_part9.py | 119 ++++++ screen_locker/tests/test_sick_tracker.py | 187 ++------- .../tests/test_sick_tracker_part2.py | 200 ++++++++++ screen_locker/tests/test_status_data.py | 374 +----------------- screen_locker/tests/test_status_data_part2.py | 212 ++++++++++ screen_locker/tests/test_status_data_part3.py | 151 +++++++ screen_locker/tests/test_status_data_part4.py | 96 +++++ screen_locker/tests/test_status_view_part2.py | 177 +-------- screen_locker/tests/test_status_view_part6.py | 193 +++++++++ screen_locker/tests/test_weekly_logic.py | 199 +--------- .../tests/test_weekly_logic_part4.py | 215 ++++++++++ .../tests/test_workout_sync_part2.py | 302 +------------- .../tests/test_workout_sync_part5.py | 227 +++++++++++ .../tests/test_workout_sync_part6.py | 131 ++++++ 21 files changed, 2522 insertions(+), 1902 deletions(-) create mode 100644 screen_locker/tests/test_manual_workout_part2.py create mode 100644 screen_locker/tests/test_runnerup_verification_part10.py create mode 100644 screen_locker/tests/test_runnerup_verification_part7.py create mode 100644 screen_locker/tests/test_runnerup_verification_part8.py create mode 100644 screen_locker/tests/test_runnerup_verification_part9.py create mode 100644 screen_locker/tests/test_sick_tracker_part2.py create mode 100644 screen_locker/tests/test_status_data_part2.py create mode 100644 screen_locker/tests/test_status_data_part3.py create mode 100644 screen_locker/tests/test_status_data_part4.py create mode 100644 screen_locker/tests/test_status_view_part6.py create mode 100644 screen_locker/tests/test_weekly_logic_part4.py create mode 100644 screen_locker/tests/test_workout_sync_part5.py create mode 100644 screen_locker/tests/test_workout_sync_part6.py diff --git a/screen_locker/tests/test_manual_workout.py b/screen_locker/tests/test_manual_workout.py index b453f82..2aaf702 100644 --- a/screen_locker/tests/test_manual_workout.py +++ b/screen_locker/tests/test_manual_workout.py @@ -8,26 +8,19 @@ from datetime import datetime, timedelta, timezone import json from typing import TYPE_CHECKING -import pytest - from screen_locker._constants import ( MANUAL_WORKOUT_BUDGET_PER_7_DAYS, MANUAL_WORKOUT_BUDGET_PER_30_DAYS, MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS, MANUAL_WORKOUT_REFLECTION_MIN_CHARS, - WORKOUT_DURATION_ACCEPT_MINUTES, ) from screen_locker._manual_workout import ( SPORT_OTHER, SPORT_TABLE_TENNIS, ManualWorkoutDraft, budget_summary, - build_entry, - build_sync_payload, count_in_window, is_budget_exhausted, - manual_sync_record_id, - validate_manual_workout, ) if TYPE_CHECKING: @@ -49,6 +42,7 @@ class TestCountInWindow: """Tests for count_in_window.""" def test_counts_only_manual_workout_type(self, tmp_path: Path) -> None: + """Counts only manual workout type.""" log_file = tmp_path / "workout_log.json" _write_logs( log_file, @@ -61,6 +55,7 @@ class TestCountInWindow: assert count_in_window(log_file, 7, today=_TODAY) == 2 def test_respects_window_cutoff(self, tmp_path: Path) -> None: + """Respects window cutoff.""" log_file = tmp_path / "workout_log.json" _write_logs( log_file, @@ -74,15 +69,18 @@ class TestCountInWindow: assert count_in_window(log_file, 30, today=_TODAY) == 3 def test_missing_file_returns_zero(self, tmp_path: Path) -> None: + """Missing file returns zero.""" log_file = tmp_path / "does_not_exist.json" assert count_in_window(log_file, 7, today=_TODAY) == 0 def test_corrupt_json_returns_zero(self, tmp_path: Path) -> None: + """Corrupt JSON returns zero.""" log_file = tmp_path / "workout_log.json" log_file.write_text("not json") assert count_in_window(log_file, 7, today=_TODAY) == 0 def test_skips_invalid_date_keys(self, tmp_path: Path) -> None: + """Skips invalid date keys.""" log_file = tmp_path / "workout_log.json" log_file.write_text( json.dumps( @@ -95,11 +93,13 @@ class TestCountInWindow: assert count_in_window(log_file, 7, today=_TODAY) == 1 def test_returns_zero_when_today_invalid(self, tmp_path: Path) -> None: + """Returns zero when today invalid.""" log_file = tmp_path / "workout_log.json" _write_logs(log_file, {"2026-07-04": "manual_workout"}) assert count_in_window(log_file, 7, today="bogus") == 0 def test_uses_today_default_when_none(self, tmp_path: Path) -> None: + """Uses today default when none.""" log_file = tmp_path / "workout_log.json" assert count_in_window(log_file, 7) == 0 @@ -125,10 +125,12 @@ class TestIsBudgetExhausted: """Tests for is_budget_exhausted.""" def test_false_when_under_budget(self, tmp_path: Path) -> None: + """False when under budget.""" log_file = tmp_path / "workout_log.json" assert is_budget_exhausted(log_file, today=_TODAY) is False def test_true_when_weekly_exhausted(self, tmp_path: Path) -> None: + """True when weekly exhausted.""" log_file = tmp_path / "workout_log.json" entries = { f"2026-07-0{4 - i}": "manual_workout" @@ -138,6 +140,7 @@ class TestIsBudgetExhausted: assert is_budget_exhausted(log_file, today=_TODAY) is True def test_true_when_monthly_exhausted(self, tmp_path: Path) -> None: + """True when monthly exhausted.""" log_file = tmp_path / "workout_log.json" # MANUAL_WORKOUT_BUDGET_PER_30_DAYS distinct dates, all strictly after # the 30d cutoff (2026-06-05) but at/before the 7d cutoff (2026-06-28), @@ -157,6 +160,7 @@ class TestBudgetSummary: """Tests for budget_summary.""" def test_renders_both_windows(self, tmp_path: Path) -> None: + """Renders both windows.""" log_file = tmp_path / "workout_log.json" _write_logs(log_file, {"2026-07-04": "manual_workout"}) summary = budget_summary(log_file, today=_TODAY) @@ -207,193 +211,3 @@ def _tt_draft(**overrides: object) -> ManualWorkoutDraft: def _other_draft(**overrides: object) -> ManualWorkoutDraft: """Build a valid "other sport" draft, with overrides applied.""" return dataclasses.replace(_OTHER_DEFAULT, **overrides) - - -class TestValidateManualWorkout: - """Tests for validate_manual_workout.""" - - def test_valid_table_tennis_returns_none(self) -> None: - assert validate_manual_workout(_tt_draft()) is None - - def test_valid_other_returns_none(self) -> None: - assert validate_manual_workout(_other_draft()) is None - - def test_rejects_unknown_sport(self) -> None: - assert validate_manual_workout(_tt_draft(sport="badminton")) is not None - - @pytest.mark.parametrize( - "field", - ["start_time", "end_time", "location_name", "transport_method", "cost"], - ) - def test_rejects_blank_required_field(self, field: str) -> None: - assert validate_manual_workout(_tt_draft(**{field: " "})) is not None - - def test_rejects_bad_time_format(self) -> None: - assert validate_manual_workout(_tt_draft(start_time="noon")) is not None - - def test_rejects_end_before_start(self) -> None: - assert ( - validate_manual_workout(_tt_draft(start_time="14:00", end_time="12:00")) - is not None - ) - - def test_rejects_too_short_duration(self) -> None: - draft = _tt_draft(start_time="12:00", end_time="12:05") - assert WORKOUT_DURATION_ACCEPT_MINUTES > 5 - assert validate_manual_workout(draft) is not None - - @pytest.mark.parametrize("rpe", [0, 11, -1]) - def test_rejects_rpe_out_of_range(self, rpe: int) -> None: - assert validate_manual_workout(_tt_draft(rpe=rpe)) is not None - - @pytest.mark.parametrize( - "field", ["matches_won", "matches_lost", "sets_won", "sets_lost"] - ) - def test_rejects_negative_table_tennis_counts(self, field: str) -> None: - assert validate_manual_workout(_tt_draft(**{field: -1})) is not None - - def test_rejects_zero_matches_played(self) -> None: - draft = _tt_draft(matches_won=0, matches_lost=0) - assert validate_manual_workout(draft) is not None - - def test_rejects_blank_racket(self) -> None: - assert validate_manual_workout(_tt_draft(racket=" ")) is not None - - def test_rejects_blank_balls(self) -> None: - assert validate_manual_workout(_tt_draft(balls="")) is not None - - def test_rejects_blank_activity_type_other(self) -> None: - assert validate_manual_workout(_other_draft(activity_type_other="")) is not None - - def test_rejects_short_activity_details(self) -> None: - assert ( - validate_manual_workout(_other_draft(activity_details="too short")) - is not None - ) - - @pytest.mark.parametrize("field", ["went_well", "to_improve", "overall_feeling"]) - def test_rejects_short_reflection_field(self, field: str) -> None: - assert validate_manual_workout(_tt_draft(**{field: "short"})) is not None - - -class TestBuildEntry: - """Tests for build_entry.""" - - def test_table_tennis_entry_shape(self) -> None: - entry = build_entry(_tt_draft()) - assert entry["type"] == "manual_workout" - assert entry["sport"] == "table_tennis" - assert entry["activity_type"] == "table tennis" - assert entry["source"] == "table tennis at Osrodek Solec" - assert entry["duration_minutes"] == "120.0" - assert entry["matches_won"] == 1 - assert entry["matches_lost"] == 4 - assert entry["sets_won"] == 2 - assert entry["sets_lost"] == 9 - assert entry["racket"] == "pro spin" - assert entry["balls"] == "nittaku" - assert "activity_details" not in entry - assert "equipment" not in entry - - def test_other_sport_entry_shape(self) -> None: - entry = build_entry(_other_draft()) - assert entry["type"] == "manual_workout" - assert entry["sport"] == "other" - assert entry["activity_type"] == "squash" - assert entry["source"] == "squash at Squash club" - assert entry["activity_details"] == "q" * MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS - assert "matches_won" not in entry - assert "racket" not in entry - - def test_duration_minutes_empty_when_unparsable(self) -> None: - entry = build_entry(_tt_draft(start_time="bogus")) - assert entry["duration_minutes"] == "" - - def test_strips_whitespace_fields(self) -> None: - entry = build_entry(_tt_draft(location_name=" Solec ", racket=" spin ")) - assert entry["location_name"] == "Solec" - assert entry["racket"] == "spin" - - def test_pain_or_injury_defaults_to_none(self) -> None: - entry = build_entry(_tt_draft()) - assert entry["pain_or_injury"] == "none" - - -# The canonical manual-workout sync payload. This literal is the cross-language -# wire contract shared with the Flutter workout_app: an IDENTICAL literal lives -# in the Dart test suite (manual_workout model test). Neither side's own -# round-trip test can catch a key-name/format drift from the other (e.g. -# went_well vs wentWell, duration_minutes formatting) — only this shared literal -# on both sides does. If you change a field here, change it there too. -_WIRE_DATE = "2026-07-13" -_WIRE_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", - reservation_phone="600100200", - techniques_practiced="topspin serve", - warm_up_minutes="10", - pain_or_injury="none", - matches_won=3, - matches_lost=1, - sets_won=7, - sets_lost=4, - racket="Butterfly", - balls="Nittaku 3-star", -) -_WIRE_PAYLOAD: dict[str, object] = { - "type": "manual_workout", - "source": "table tennis at Solec", - "sport": "table_tennis", - "activity_type": "table tennis", - "start_time": "18:00", - "end_time": "19:30", - "duration_minutes": "90.0", - "location_name": "Solec", - "transport_method": "bike", - "cost": "40 PLN", - "reservation_phone": "600100200", - "rpe": 6, - "techniques_practiced": "topspin serve", - "warm_up_minutes": "10", - "pain_or_injury": "none", - "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", - "kind": "manual_workout", - "date": "2026-07-13", -} - - -class TestSyncWireFormat: - """Cross-language wire-format contract for the manual-workout sync payload.""" - - def test_build_sync_payload_matches_fixture(self) -> None: - assert build_sync_payload(_WIRE_DRAFT, _WIRE_DATE) == _WIRE_PAYLOAD - - def test_sync_payload_is_json_serializable(self) -> None: - # The Dart side round-trips the same literal through JSON; guard that our - # payload contains only JSON-native types (no stray dataclass/objects). - restored = json.loads(json.dumps(build_sync_payload(_WIRE_DRAFT, _WIRE_DATE))) - assert restored == _WIRE_PAYLOAD - - def test_record_id_is_stable_and_prefixed(self) -> None: - assert manual_sync_record_id(_WIRE_DATE, "18:00") == "manual:2026-07-13T18:00" - - def test_kind_discriminator_present_and_typed(self) -> None: - payload = build_sync_payload(_WIRE_DRAFT, _WIRE_DATE) - # Absent-kind means a StrongLifts session; a manual must be tagged. - assert payload["kind"] == "manual_workout" diff --git a/screen_locker/tests/test_manual_workout_part2.py b/screen_locker/tests/test_manual_workout_part2.py new file mode 100644 index 0000000..1464c0a --- /dev/null +++ b/screen_locker/tests/test_manual_workout_part2.py @@ -0,0 +1,238 @@ +"""Tests for the manual-workout pure-logic module.""" +# pylint: disable=protected-access + +from __future__ import annotations + +import json + +import pytest + +from screen_locker._constants import ( + MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS, + WORKOUT_DURATION_ACCEPT_MINUTES, +) +from screen_locker._manual_workout import ( + SPORT_TABLE_TENNIS, + ManualWorkoutDraft, + build_entry, + build_sync_payload, + manual_sync_record_id, + validate_manual_workout, +) +from screen_locker.tests.test_manual_workout import _other_draft, _tt_draft + +_TODAY = "2026-07-05" + + +class TestValidateManualWorkout: + """Tests for validate_manual_workout.""" + + def test_valid_table_tennis_returns_none(self) -> None: + """Valid table tennis returns none.""" + assert validate_manual_workout(_tt_draft()) is None + + def test_valid_other_returns_none(self) -> None: + """Valid other returns none.""" + assert validate_manual_workout(_other_draft()) is None + + def test_rejects_unknown_sport(self) -> None: + """Rejects unknown sport.""" + assert validate_manual_workout(_tt_draft(sport="badminton")) is not None + + @pytest.mark.parametrize( + "field", + ["start_time", "end_time", "location_name", "transport_method", "cost"], + ) + def test_rejects_blank_required_field(self, field: str) -> None: + """Rejects blank required field.""" + assert validate_manual_workout(_tt_draft(**{field: " "})) is not None + + def test_rejects_bad_time_format(self) -> None: + """Rejects bad time format.""" + assert validate_manual_workout(_tt_draft(start_time="noon")) is not None + + def test_rejects_end_before_start(self) -> None: + """Rejects end before start.""" + assert ( + validate_manual_workout(_tt_draft(start_time="14:00", end_time="12:00")) + is not None + ) + + def test_rejects_too_short_duration(self) -> None: + """Rejects too short duration.""" + draft = _tt_draft(start_time="12:00", end_time="12:05") + assert WORKOUT_DURATION_ACCEPT_MINUTES > 5 + assert validate_manual_workout(draft) is not None + + @pytest.mark.parametrize("rpe", [0, 11, -1]) + def test_rejects_rpe_out_of_range(self, rpe: int) -> None: + """Rejects rpe out of range.""" + assert validate_manual_workout(_tt_draft(rpe=rpe)) is not None + + @pytest.mark.parametrize( + "field", ["matches_won", "matches_lost", "sets_won", "sets_lost"] + ) + def test_rejects_negative_table_tennis_counts(self, field: str) -> None: + """Rejects negative table tennis counts.""" + assert validate_manual_workout(_tt_draft(**{field: -1})) is not None + + def test_rejects_zero_matches_played(self) -> None: + """Rejects zero matches played.""" + draft = _tt_draft(matches_won=0, matches_lost=0) + assert validate_manual_workout(draft) is not None + + def test_rejects_blank_racket(self) -> None: + """Rejects blank racket.""" + assert validate_manual_workout(_tt_draft(racket=" ")) is not None + + def test_rejects_blank_balls(self) -> None: + """Rejects blank balls.""" + assert validate_manual_workout(_tt_draft(balls="")) is not None + + def test_rejects_blank_activity_type_other(self) -> None: + """Rejects blank activity type other.""" + assert validate_manual_workout(_other_draft(activity_type_other="")) is not None + + def test_rejects_short_activity_details(self) -> None: + """Rejects short activity details.""" + assert ( + validate_manual_workout(_other_draft(activity_details="too short")) + is not None + ) + + @pytest.mark.parametrize("field", ["went_well", "to_improve", "overall_feeling"]) + def test_rejects_short_reflection_field(self, field: str) -> None: + """Rejects short reflection field.""" + assert validate_manual_workout(_tt_draft(**{field: "short"})) is not None + + +class TestBuildEntry: + """Tests for build_entry.""" + + def test_table_tennis_entry_shape(self) -> None: + """Table tennis entry shape.""" + entry = build_entry(_tt_draft()) + assert entry["type"] == "manual_workout" + assert entry["sport"] == "table_tennis" + assert entry["activity_type"] == "table tennis" + assert entry["source"] == "table tennis at Osrodek Solec" + assert entry["duration_minutes"] == "120.0" + assert entry["matches_won"] == 1 + assert entry["matches_lost"] == 4 + assert entry["sets_won"] == 2 + assert entry["sets_lost"] == 9 + assert entry["racket"] == "pro spin" + assert entry["balls"] == "nittaku" + assert "activity_details" not in entry + assert "equipment" not in entry + + def test_other_sport_entry_shape(self) -> None: + """Other sport entry shape.""" + entry = build_entry(_other_draft()) + assert entry["type"] == "manual_workout" + assert entry["sport"] == "other" + assert entry["activity_type"] == "squash" + assert entry["source"] == "squash at Squash club" + assert entry["activity_details"] == "q" * MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS + assert "matches_won" not in entry + assert "racket" not in entry + + def test_duration_minutes_empty_when_unparsable(self) -> None: + """Duration minutes empty when unparsable.""" + entry = build_entry(_tt_draft(start_time="bogus")) + assert entry["duration_minutes"] == "" + + def test_strips_whitespace_fields(self) -> None: + """Strips whitespace fields.""" + entry = build_entry(_tt_draft(location_name=" Solec ", racket=" spin ")) + assert entry["location_name"] == "Solec" + assert entry["racket"] == "spin" + + def test_pain_or_injury_defaults_to_none(self) -> None: + """Pain or injury defaults to none.""" + entry = build_entry(_tt_draft()) + assert entry["pain_or_injury"] == "none" + + +# The canonical manual-workout sync payload. This literal is the cross-language +# wire contract shared with the Flutter workout_app: an IDENTICAL literal lives +# in the Dart test suite (manual_workout model test). Neither side's own +# round-trip test can catch a key-name/format drift from the other (e.g. +# went_well vs wentWell, duration_minutes formatting) — only this shared literal +# on both sides does. If you change a field here, change it there too. +_WIRE_DATE = "2026-07-13" +_WIRE_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", + reservation_phone="600100200", + techniques_practiced="topspin serve", + warm_up_minutes="10", + pain_or_injury="none", + matches_won=3, + matches_lost=1, + sets_won=7, + sets_lost=4, + racket="Butterfly", + balls="Nittaku 3-star", +) +_WIRE_PAYLOAD: dict[str, object] = { + "type": "manual_workout", + "source": "table tennis at Solec", + "sport": "table_tennis", + "activity_type": "table tennis", + "start_time": "18:00", + "end_time": "19:30", + "duration_minutes": "90.0", + "location_name": "Solec", + "transport_method": "bike", + "cost": "40 PLN", + "reservation_phone": "600100200", + "rpe": 6, + "techniques_practiced": "topspin serve", + "warm_up_minutes": "10", + "pain_or_injury": "none", + "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", + "kind": "manual_workout", + "date": "2026-07-13", +} + + +class TestSyncWireFormat: + """Cross-language wire-format contract for the manual-workout sync payload.""" + + def test_build_sync_payload_matches_fixture(self) -> None: + """Build sync payload matches fixture.""" + assert build_sync_payload(_WIRE_DRAFT, _WIRE_DATE) == _WIRE_PAYLOAD + + def test_sync_payload_is_json_serializable(self) -> None: + # The Dart side round-trips the same literal through JSON; guard that our + # payload contains only JSON-native types (no stray dataclass/objects). + """Sync payload is JSON serializable.""" + restored = json.loads(json.dumps(build_sync_payload(_WIRE_DRAFT, _WIRE_DATE))) + assert restored == _WIRE_PAYLOAD + + def test_record_id_is_stable_and_prefixed(self) -> None: + """Record ID is stable and prefixed.""" + assert manual_sync_record_id(_WIRE_DATE, "18:00") == "manual:2026-07-13T18:00" + + def test_kind_discriminator_present_and_typed(self) -> None: + """Kind discriminator present and typed.""" + payload = build_sync_payload(_WIRE_DRAFT, _WIRE_DATE) + # Absent-kind means a StrongLifts session; a manual must be tagged. + assert payload["kind"] == "manual_workout" diff --git a/screen_locker/tests/test_runnerup_verification_part1.py b/screen_locker/tests/test_runnerup_verification_part1.py index 1c34047..1ccd57e 100644 --- a/screen_locker/tests/test_runnerup_verification_part1.py +++ b/screen_locker/tests/test_runnerup_verification_part1.py @@ -2,14 +2,13 @@ from __future__ import annotations -import shutil 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 + from unittest.mock import MagicMock # Minimal valid TCX XML for a 40-minute, 6-km run. _TCX_RUNNING = """\ @@ -181,207 +180,3 @@ class TestValidateRunnerupData: # --------------------------------------------------------------------------- # _parse_tcx # --------------------------------------------------------------------------- - - -class TestParseTcx: - """Tests for _parse_tcx (lines 109-134).""" - - def test_parses_valid_running_tcx( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Valid Running TCX returns correct sport/duration/distance dict.""" - locker = create_locker(mock_tk, tmp_path) - path = _write_tcx(tmp_path, _TCX_RUNNING) - result = locker._parse_tcx(path) - assert result is not None - assert result["sport"] == 0 # Running - assert result["duration_seconds"] == 2400 - assert result["distance_m"] == 6000.0 - - def test_parse_error_returns_none( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Malformed XML is caught by ParseError; returns None.""" - locker = create_locker(mock_tk, tmp_path) - path = _write_tcx(tmp_path, " None: - """TCX with no element returns None.""" - locker = create_locker(mock_tk, tmp_path) - tcx = """\ - - - -""" - path = _write_tcx(tmp_path, tcx) - assert locker._parse_tcx(path) is None - - def test_multi_lap_sums_correctly( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Two laps: totals are summed across both.""" - locker = create_locker(mock_tk, tmp_path) - path = _write_tcx(tmp_path, _TCX_MULTI_LAP) - result = locker._parse_tcx(path) - assert result is not None - assert result["duration_seconds"] == 2400 - assert result["distance_m"] == 6000.0 - - -# --------------------------------------------------------------------------- -# _find_runnerup_exports_for_date -# --------------------------------------------------------------------------- - - -class TestFindRunnerupExportsForDate: - """Tests for _find_runnerup_exports_for_date (lines 77-88).""" - - def test_returns_empty_when_adb_fails( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """_run_adb returning False → empty list.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "_run_adb", MagicMock(return_value=(False, ""))) - assert locker._find_runnerup_exports_for_date("2024-03-15") == [] - - def test_returns_empty_when_no_matching_files( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """ADB listing with no date-matching .tcx files → empty list.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_run_adb", - MagicMock(return_value=(True, "RunnerUp_2024-01-01-10-00-00.tcx\n")), - ) - assert locker._find_runnerup_exports_for_date("2024-03-15") == [] - - def test_returns_matching_tcx_files( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Matching filename with date string → path included in result.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_run_adb", - MagicMock(return_value=(True, "RunnerUp_2024-03-15-10-30-00_act.tcx\n")), - ) - result = locker._find_runnerup_exports_for_date("2024-03-15") - assert len(result) >= 1 - assert "2024-03-15" in result[0] - assert result[0].endswith(".tcx") - - def test_deduplicates_across_dirs( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Same remote path appearing in two dirs is not duplicated.""" - locker = create_locker(mock_tk, tmp_path) - # Both export dirs return the same filename (different dirs → different paths) - object.__setattr__( - locker, - "_run_adb", - MagicMock(return_value=(True, "RunnerUp_2024-03-15-10-30-00_act.tcx\n")), - ) - - result = locker._find_runnerup_exports_for_date("2024-03-15") - # Paths come from different dirs so both are included, but no duplicates - assert len(result) == len(set(result)) - - def test_skips_empty_listing( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Empty stdout from ADB is skipped without adding entries.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "_run_adb", MagicMock(return_value=(True, " \n"))) - assert locker._find_runnerup_exports_for_date("2024-03-15") == [] - - -# --------------------------------------------------------------------------- -# _pull_and_parse_tcx -# --------------------------------------------------------------------------- - - -class TestPullAndParseTcx: - """Tests for _pull_and_parse_tcx (lines 92-101).""" - - def test_returns_none_when_pull_fails( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Failed adb pull → None returned.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "_run_adb", MagicMock(return_value=(False, ""))) - assert locker._pull_and_parse_tcx("/sdcard/some.tcx") is None - - def test_returns_none_when_file_not_written( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """adb pull succeeds but local file absent (race) → None.""" - locker = create_locker(mock_tk, tmp_path) - # _run_adb returns True but does not actually write the file. - object.__setattr__(locker, "_run_adb", MagicMock(return_value=(True, ""))) - assert locker._pull_and_parse_tcx("/sdcard/some.tcx") is None - - def test_returns_parsed_data_on_success( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Successful pull + valid TCX → parsed activity dict.""" - locker = create_locker(mock_tk, tmp_path) - tcx_src = tmp_path / "source.tcx" - tcx_src.write_text(_TCX_RUNNING) - - def _fake_pull(args: list[str]) -> tuple[bool, str]: - if args[0] == "pull": - shutil.copy(str(tcx_src), args[2]) - return True, "" - return True, "" - - object.__setattr__(locker, "_run_adb", MagicMock(side_effect=_fake_pull)) - result = locker._pull_and_parse_tcx("/sdcard/activity.tcx") - assert result is not None - assert result["sport"] == 0 - assert result["duration_seconds"] == 2400 - - -# --------------------------------------------------------------------------- -# _verify_runnerup_via_files -# --------------------------------------------------------------------------- diff --git a/screen_locker/tests/test_runnerup_verification_part10.py b/screen_locker/tests/test_runnerup_verification_part10.py new file mode 100644 index 0000000..79cf36c --- /dev/null +++ b/screen_locker/tests/test_runnerup_verification_part10.py @@ -0,0 +1,295 @@ +"""Tests for _scan_and_fill_week_runnerup's skip and error paths. + +Split out of test_runnerup_verification_part7.py for the 250-line cap. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker._log_io import load_workout_log +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + +# Minimal valid TCX XML for a 40-minute, 6-km run. +_TCX_RUNNING = """\ + + + + + + 2400.0 + 6000.0 + + + + +""" + +# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS). +_TCX_GYM = """\ + + + + + + 3600.0 + 0.0 + + + + +""" + +# Two laps that together make a valid run. +_TCX_MULTI_LAP = """\ + + + + + + 1200.0 + 3000.0 + + + 1200.0 + 3000.0 + + + + +""" + + +class TestScanAndFillWeekRunnerupSkips: + """Days the scan declines to fill, and failures it survives.""" + + def test_skips_date_when_no_exports( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """No exports for a date → date skipped, count stays 0.""" + locker = create_locker(mock_tk, tmp_path) + log_file = tmp_path / "log.json" + log_file.write_text("{}") + object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) + object.__setattr__( + locker, + "_find_runnerup_exports_for_date", + MagicMock(return_value=[]), + ) + assert locker._scan_and_fill_week_runnerup(log_file) == 0 + + def test_skips_unreadable_tcx( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """_pull_and_parse_tcx returns None → remote skipped, not filled.""" + locker = create_locker(mock_tk, tmp_path) + log_file = tmp_path / "log.json" + log_file.write_text("{}") + object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) + object.__setattr__( + locker, + "_find_runnerup_exports_for_date", + MagicMock(return_value=["/sdcard/run.tcx"]), + ) + object.__setattr__(locker, "_pull_and_parse_tcx", MagicMock(return_value=None)) + assert locker._scan_and_fill_week_runnerup(log_file) == 0 + + def test_skips_not_verified_export( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """_validate_runnerup_data returns not-verified → date not filled. + + Both duration and distance are held under their minimums -- the + criteria are an OR, so a fixture qualifying on either alone would + verify instead of exercising the not-verified path this test is + about. + """ + locker = create_locker(mock_tk, tmp_path) + log_file = tmp_path / "log.json" + log_file.write_text("{}") + object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) + object.__setattr__( + locker, + "_find_runnerup_exports_for_date", + MagicMock(return_value=["/sdcard/run.tcx"]), + ) + object.__setattr__( + locker, + "_pull_and_parse_tcx", + MagicMock( + return_value={"sport": 0, "duration_seconds": 60, "distance_m": 1000} + ), + ) + assert locker._scan_and_fill_week_runnerup(log_file) == 0 + + def test_rescanning_an_already_filled_day_is_a_no_op( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """A day whose run is already logged isn't appended twice (dedup).""" + locker = create_locker(mock_tk, tmp_path) + log_file = tmp_path / "log.json" + log_file.write_text("{}") + + object.__setattr__( + locker, + "_find_runnerup_exports_for_date", + MagicMock(return_value=["/sdcard/run.tcx"]), + ) + object.__setattr__( + locker, + "_pull_and_parse_tcx", + MagicMock( + return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000} + ), + ) + object.__setattr__( + locker, + "_validate_runnerup_data", + MagicMock(return_value=("verified", "ok")), + ) + + with patch( + "screen_locker._log_mixin.compute_entry_hmac", + return_value="sig", + ): + first = locker._try_fill_runnerup_for_date("2026-07-13", log_file) + second = locker._try_fill_runnerup_for_date("2026-07-13", log_file) + + assert first is True + assert second is False + assert len(load_workout_log(log_file)["2026-07-13"]) == 1 + + def test_save_error_is_swallowed_not_raised( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """An OSError saving the log only warns — the scan still completes. + + Persistence now lives in the write chokepoint (``write_signed_entry``), + which logs and swallows save errors rather than propagating them. + """ + locker = create_locker(mock_tk, tmp_path) + fail_log = tmp_path / "log.json" + fail_log.write_text("{}") + + object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) + object.__setattr__( + locker, + "_find_runnerup_exports_for_date", + MagicMock(return_value=["/sdcard/run.tcx"]), + ) + object.__setattr__( + locker, + "_pull_and_parse_tcx", + MagicMock( + return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000} + ), + ) + object.__setattr__( + locker, + "_validate_runnerup_data", + MagicMock(return_value=("verified", "ok")), + ) + + with ( + patch( + "screen_locker._log_mixin.compute_entry_hmac", + return_value="sig", + ), + patch( + "screen_locker._log_mixin.json.dump", + side_effect=OSError("disk full"), + ), + ): + result = locker._scan_and_fill_week_runnerup(fail_log) + + # The append still reports success; only the save failure is warned. + assert result >= 1 + + def test_handles_corrupt_log_file( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Corrupt log JSON → starts with empty dict, still works.""" + locker = create_locker(mock_tk, tmp_path) + log_file = tmp_path / "log.json" + log_file.write_text("not-json") + object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) + object.__setattr__( + locker, + "_find_runnerup_exports_for_date", + MagicMock(return_value=[]), + ) + # Should not raise; returns 0 (no exports found). + assert locker._scan_and_fill_week_runnerup(log_file) == 0 + + def test_hmac_none_still_fills_entry( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """HMAC key absent (compute_entry_hmac returns None) → entry still written.""" + locker = create_locker(mock_tk, tmp_path) + log_file = tmp_path / "log.json" + log_file.write_text("{}") + object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) + object.__setattr__( + locker, + "_find_runnerup_exports_for_date", + MagicMock(return_value=["/sdcard/run.tcx"]), + ) + object.__setattr__( + locker, + "_pull_and_parse_tcx", + MagicMock( + return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000} + ), + ) + object.__setattr__( + locker, + "_validate_runnerup_data", + MagicMock(return_value=("verified", "Running: 6.0 km in 40 min")), + ) + + with patch( + "screen_locker._log_mixin.compute_entry_hmac", + return_value=None, + ): + result = locker._scan_and_fill_week_runnerup(log_file) + + assert result > 0 + # No "hmac" key when signature is None; each day holds a list of entries. + logs = json.loads(log_file.read_text()) + for entries in logs.values(): + for entry in entries: + assert "hmac" not in entry + + +# --------------------------------------------------------------------------- +# _find_runnerup_package +# --------------------------------------------------------------------------- diff --git a/screen_locker/tests/test_runnerup_verification_part3.py b/screen_locker/tests/test_runnerup_verification_part3.py index 6958cb4..33af608 100644 --- a/screen_locker/tests/test_runnerup_verification_part3.py +++ b/screen_locker/tests/test_runnerup_verification_part3.py @@ -2,12 +2,7 @@ from __future__ import annotations -import json -from typing import TYPE_CHECKING, Any -from unittest.mock import MagicMock, patch - -from screen_locker._log_io import load_workout_log -from screen_locker.tests.conftest import create_locker +from typing import TYPE_CHECKING if TYPE_CHECKING: from pathlib import Path @@ -80,319 +75,3 @@ def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str: # --------------------------------------------------------------------------- # _validate_runnerup_data # --------------------------------------------------------------------------- - - -class TestScanAndFillWeekRunnerup: - """Tests for _scan_and_fill_week_runnerup (lines 186-248).""" - - def test_returns_zero_when_no_phone( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """No ADB device → 0 filled.""" - locker = create_locker(mock_tk, tmp_path) - log_file = tmp_path / "log.json" - log_file.write_text("{}") - object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=False)) - assert locker._scan_and_fill_week_runnerup(log_file) == 0 - - def test_returns_zero_when_all_days_already_logged( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """All days this week already have counted workouts → 0 new fills.""" - from datetime import date, timedelta - - locker = create_locker(mock_tk, tmp_path) - log_file = tmp_path / "log.json" - - # Fill Mon-today with phone_verified entries. - today = date.today() - monday = today - timedelta(days=today.weekday()) - logs: dict[str, Any] = {} - cur = monday - while cur <= today: - logs[cur.strftime("%Y-%m-%d")] = { - "workout_data": {"type": "phone_verified"} - } - cur += timedelta(days=1) - log_file.write_text(json.dumps(logs)) - - object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) - object.__setattr__( - locker, "_find_runnerup_exports_for_date", MagicMock(return_value=[]) - ) - assert locker._scan_and_fill_week_runnerup(log_file) == 0 - - def test_fills_gap_for_unlogged_day( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Gap in log + exports found + validated → entry written, count > 0.""" - locker = create_locker(mock_tk, tmp_path) - log_file = tmp_path / "log.json" - log_file.write_text("{}") - - object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) - object.__setattr__( - locker, - "_find_runnerup_exports_for_date", - MagicMock(return_value=["/sdcard/run.tcx"]), - ) - object.__setattr__( - locker, - "_pull_and_parse_tcx", - MagicMock( - return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000} - ), - ) - object.__setattr__( - locker, - "_validate_runnerup_data", - MagicMock(return_value=("verified", "Running: 6.0 km in 40 min")), - ) - - with patch( - "screen_locker._log_mixin.compute_entry_hmac", - return_value="sig", - ): - result = locker._scan_and_fill_week_runnerup(log_file) - - assert result > 0 - logs = json.loads(log_file.read_text()) - # At least one date should have been filled; each day holds a list. - types = [ - entry.get("workout_data", {}).get("type") - for entries in logs.values() - for entry in entries - ] - assert "runnerup_verified" in types - - def test_skips_date_when_no_exports( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """No exports for a date → date skipped, count stays 0.""" - locker = create_locker(mock_tk, tmp_path) - log_file = tmp_path / "log.json" - log_file.write_text("{}") - object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) - object.__setattr__( - locker, - "_find_runnerup_exports_for_date", - MagicMock(return_value=[]), - ) - assert locker._scan_and_fill_week_runnerup(log_file) == 0 - - def test_skips_unreadable_tcx( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """_pull_and_parse_tcx returns None → remote skipped, not filled.""" - locker = create_locker(mock_tk, tmp_path) - log_file = tmp_path / "log.json" - log_file.write_text("{}") - object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) - object.__setattr__( - locker, - "_find_runnerup_exports_for_date", - MagicMock(return_value=["/sdcard/run.tcx"]), - ) - object.__setattr__(locker, "_pull_and_parse_tcx", MagicMock(return_value=None)) - assert locker._scan_and_fill_week_runnerup(log_file) == 0 - - def test_skips_not_verified_export( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """_validate_runnerup_data returns not-verified → date not filled. - - Both duration and distance are held under their minimums -- the - criteria are an OR, so a fixture qualifying on either alone would - verify instead of exercising the not-verified path this test is - about. - """ - locker = create_locker(mock_tk, tmp_path) - log_file = tmp_path / "log.json" - log_file.write_text("{}") - object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) - object.__setattr__( - locker, - "_find_runnerup_exports_for_date", - MagicMock(return_value=["/sdcard/run.tcx"]), - ) - object.__setattr__( - locker, - "_pull_and_parse_tcx", - MagicMock( - return_value={"sport": 0, "duration_seconds": 60, "distance_m": 1000} - ), - ) - assert locker._scan_and_fill_week_runnerup(log_file) == 0 - - def test_rescanning_an_already_filled_day_is_a_no_op( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """A day whose run is already logged isn't appended twice (dedup).""" - locker = create_locker(mock_tk, tmp_path) - log_file = tmp_path / "log.json" - log_file.write_text("{}") - - object.__setattr__( - locker, - "_find_runnerup_exports_for_date", - MagicMock(return_value=["/sdcard/run.tcx"]), - ) - object.__setattr__( - locker, - "_pull_and_parse_tcx", - MagicMock( - return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000} - ), - ) - object.__setattr__( - locker, - "_validate_runnerup_data", - MagicMock(return_value=("verified", "ok")), - ) - - with patch( - "screen_locker._log_mixin.compute_entry_hmac", - return_value="sig", - ): - first = locker._try_fill_runnerup_for_date("2026-07-13", log_file) - second = locker._try_fill_runnerup_for_date("2026-07-13", log_file) - - assert first is True - assert second is False - assert len(load_workout_log(log_file)["2026-07-13"]) == 1 - - def test_save_error_is_swallowed_not_raised( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """An OSError saving the log only warns — the scan still completes. - - Persistence now lives in the write chokepoint (``write_signed_entry``), - which logs and swallows save errors rather than propagating them. - """ - locker = create_locker(mock_tk, tmp_path) - fail_log = tmp_path / "log.json" - fail_log.write_text("{}") - - object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) - object.__setattr__( - locker, - "_find_runnerup_exports_for_date", - MagicMock(return_value=["/sdcard/run.tcx"]), - ) - object.__setattr__( - locker, - "_pull_and_parse_tcx", - MagicMock( - return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000} - ), - ) - object.__setattr__( - locker, - "_validate_runnerup_data", - MagicMock(return_value=("verified", "ok")), - ) - - with ( - patch( - "screen_locker._log_mixin.compute_entry_hmac", - return_value="sig", - ), - patch( - "screen_locker._log_mixin.json.dump", - side_effect=OSError("disk full"), - ), - ): - result = locker._scan_and_fill_week_runnerup(fail_log) - - # The append still reports success; only the save failure is warned. - assert result >= 1 - - def test_handles_corrupt_log_file( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Corrupt log JSON → starts with empty dict, still works.""" - locker = create_locker(mock_tk, tmp_path) - log_file = tmp_path / "log.json" - log_file.write_text("not-json") - object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) - object.__setattr__( - locker, - "_find_runnerup_exports_for_date", - MagicMock(return_value=[]), - ) - # Should not raise; returns 0 (no exports found). - assert locker._scan_and_fill_week_runnerup(log_file) == 0 - - def test_hmac_none_still_fills_entry( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """HMAC key absent (compute_entry_hmac returns None) → entry still written.""" - locker = create_locker(mock_tk, tmp_path) - log_file = tmp_path / "log.json" - log_file.write_text("{}") - object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) - object.__setattr__( - locker, - "_find_runnerup_exports_for_date", - MagicMock(return_value=["/sdcard/run.tcx"]), - ) - object.__setattr__( - locker, - "_pull_and_parse_tcx", - MagicMock( - return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000} - ), - ) - object.__setattr__( - locker, - "_validate_runnerup_data", - MagicMock(return_value=("verified", "Running: 6.0 km in 40 min")), - ) - - with patch( - "screen_locker._log_mixin.compute_entry_hmac", - return_value=None, - ): - result = locker._scan_and_fill_week_runnerup(log_file) - - assert result > 0 - # No "hmac" key when signature is None; each day holds a list of entries. - logs = json.loads(log_file.read_text()) - for entries in logs.values(): - for entry in entries: - assert "hmac" not in entry - - -# --------------------------------------------------------------------------- -# _find_runnerup_package -# --------------------------------------------------------------------------- diff --git a/screen_locker/tests/test_runnerup_verification_part7.py b/screen_locker/tests/test_runnerup_verification_part7.py new file mode 100644 index 0000000..a41abc2 --- /dev/null +++ b/screen_locker/tests/test_runnerup_verification_part7.py @@ -0,0 +1,157 @@ +"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + +# Minimal valid TCX XML for a 40-minute, 6-km run. +_TCX_RUNNING = """\ + + + + + + 2400.0 + 6000.0 + + + + +""" + +# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS). +_TCX_GYM = """\ + + + + + + 3600.0 + 0.0 + + + + +""" + +# Two laps that together make a valid run. +_TCX_MULTI_LAP = """\ + + + + + + 1200.0 + 3000.0 + + + 1200.0 + 3000.0 + + + + +""" + + +class TestScanAndFillWeekRunnerup: + """Tests for _scan_and_fill_week_runnerup (lines 186-248).""" + + def test_returns_zero_when_no_phone( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """No ADB device → 0 filled.""" + locker = create_locker(mock_tk, tmp_path) + log_file = tmp_path / "log.json" + log_file.write_text("{}") + object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=False)) + assert locker._scan_and_fill_week_runnerup(log_file) == 0 + + def test_returns_zero_when_all_days_already_logged( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """All days this week already have counted workouts → 0 new fills.""" + from datetime import date, timedelta + + locker = create_locker(mock_tk, tmp_path) + log_file = tmp_path / "log.json" + + # Fill Mon-today with phone_verified entries. + today = date.today() + monday = today - timedelta(days=today.weekday()) + logs: dict[str, Any] = {} + cur = monday + while cur <= today: + logs[cur.strftime("%Y-%m-%d")] = { + "workout_data": {"type": "phone_verified"} + } + cur += timedelta(days=1) + log_file.write_text(json.dumps(logs)) + + object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) + object.__setattr__( + locker, "_find_runnerup_exports_for_date", MagicMock(return_value=[]) + ) + assert locker._scan_and_fill_week_runnerup(log_file) == 0 + + def test_fills_gap_for_unlogged_day( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Gap in log + exports found + validated → entry written, count > 0.""" + locker = create_locker(mock_tk, tmp_path) + log_file = tmp_path / "log.json" + log_file.write_text("{}") + + object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True)) + object.__setattr__( + locker, + "_find_runnerup_exports_for_date", + MagicMock(return_value=["/sdcard/run.tcx"]), + ) + object.__setattr__( + locker, + "_pull_and_parse_tcx", + MagicMock( + return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000} + ), + ) + object.__setattr__( + locker, + "_validate_runnerup_data", + MagicMock(return_value=("verified", "Running: 6.0 km in 40 min")), + ) + + with patch( + "screen_locker._log_mixin.compute_entry_hmac", + return_value="sig", + ): + result = locker._scan_and_fill_week_runnerup(log_file) + + assert result > 0 + logs = json.loads(log_file.read_text()) + # At least one date should have been filled; each day holds a list. + types = [ + entry.get("workout_data", {}).get("type") + for entries in logs.values() + for entry in entries + ] + assert "runnerup_verified" in types diff --git a/screen_locker/tests/test_runnerup_verification_part8.py b/screen_locker/tests/test_runnerup_verification_part8.py new file mode 100644 index 0000000..2fd3fd4 --- /dev/null +++ b/screen_locker/tests/test_runnerup_verification_part8.py @@ -0,0 +1,213 @@ +"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from screen_locker.tests.conftest import create_locker +from screen_locker.tests.test_runnerup_verification_part1 import _write_tcx + +if TYPE_CHECKING: + from pathlib import Path + +# Minimal valid TCX XML for a 40-minute, 6-km run. +_TCX_RUNNING = """\ + + + + + + 2400.0 + 6000.0 + + + + +""" + +# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS). +_TCX_GYM = """\ + + + + + + 3600.0 + 0.0 + + + + +""" + +# Two laps that together make a valid run. +_TCX_MULTI_LAP = """\ + + + + + + 1200.0 + 3000.0 + + + 1200.0 + 3000.0 + + + + +""" + + +class TestParseTcx: + """Tests for _parse_tcx (lines 109-134).""" + + def test_parses_valid_running_tcx( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Valid Running TCX returns correct sport/duration/distance dict.""" + locker = create_locker(mock_tk, tmp_path) + path = _write_tcx(tmp_path, _TCX_RUNNING) + result = locker._parse_tcx(path) + assert result is not None + assert result["sport"] == 0 # Running + assert result["duration_seconds"] == 2400 + assert result["distance_m"] == 6000.0 + + def test_parse_error_returns_none( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Malformed XML is caught by ParseError; returns None.""" + locker = create_locker(mock_tk, tmp_path) + path = _write_tcx(tmp_path, " None: + """TCX with no element returns None.""" + locker = create_locker(mock_tk, tmp_path) + tcx = """\ + + + +""" + path = _write_tcx(tmp_path, tcx) + assert locker._parse_tcx(path) is None + + def test_multi_lap_sums_correctly( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Two laps: totals are summed across both.""" + locker = create_locker(mock_tk, tmp_path) + path = _write_tcx(tmp_path, _TCX_MULTI_LAP) + result = locker._parse_tcx(path) + assert result is not None + assert result["duration_seconds"] == 2400 + assert result["distance_m"] == 6000.0 + + +# --------------------------------------------------------------------------- +# _find_runnerup_exports_for_date +# --------------------------------------------------------------------------- + + +class TestFindRunnerupExportsForDate: + """Tests for _find_runnerup_exports_for_date (lines 77-88).""" + + def test_returns_empty_when_adb_fails( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """_run_adb returning False → empty list.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "_run_adb", MagicMock(return_value=(False, ""))) + assert locker._find_runnerup_exports_for_date("2024-03-15") == [] + + def test_returns_empty_when_no_matching_files( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """ADB listing with no date-matching .tcx files → empty list.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_run_adb", + MagicMock(return_value=(True, "RunnerUp_2024-01-01-10-00-00.tcx\n")), + ) + assert locker._find_runnerup_exports_for_date("2024-03-15") == [] + + def test_returns_matching_tcx_files( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Matching filename with date string → path included in result.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_run_adb", + MagicMock(return_value=(True, "RunnerUp_2024-03-15-10-30-00_act.tcx\n")), + ) + result = locker._find_runnerup_exports_for_date("2024-03-15") + assert len(result) >= 1 + assert "2024-03-15" in result[0] + assert result[0].endswith(".tcx") + + def test_deduplicates_across_dirs( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Same remote path appearing in two dirs is not duplicated.""" + locker = create_locker(mock_tk, tmp_path) + # Both export dirs return the same filename (different dirs → different paths) + object.__setattr__( + locker, + "_run_adb", + MagicMock(return_value=(True, "RunnerUp_2024-03-15-10-30-00_act.tcx\n")), + ) + + result = locker._find_runnerup_exports_for_date("2024-03-15") + # Paths come from different dirs so both are included, but no duplicates + assert len(result) == len(set(result)) + + def test_skips_empty_listing( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Empty stdout from ADB is skipped without adding entries.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "_run_adb", MagicMock(return_value=(True, " \n"))) + assert locker._find_runnerup_exports_for_date("2024-03-15") == [] + + +# --------------------------------------------------------------------------- +# _pull_and_parse_tcx +# --------------------------------------------------------------------------- diff --git a/screen_locker/tests/test_runnerup_verification_part9.py b/screen_locker/tests/test_runnerup_verification_part9.py new file mode 100644 index 0000000..26dcb39 --- /dev/null +++ b/screen_locker/tests/test_runnerup_verification_part9.py @@ -0,0 +1,119 @@ +"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py.""" + +from __future__ import annotations + +import shutil +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 + +# Minimal valid TCX XML for a 40-minute, 6-km run. +_TCX_RUNNING = """\ + + + + + + 2400.0 + 6000.0 + + + + +""" + +# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS). +_TCX_GYM = """\ + + + + + + 3600.0 + 0.0 + + + + +""" + +# Two laps that together make a valid run. +_TCX_MULTI_LAP = """\ + + + + + + 1200.0 + 3000.0 + + + 1200.0 + 3000.0 + + + + +""" + + +class TestPullAndParseTcx: + """Tests for _pull_and_parse_tcx (lines 92-101).""" + + def test_returns_none_when_pull_fails( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Failed adb pull → None returned.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "_run_adb", MagicMock(return_value=(False, ""))) + assert locker._pull_and_parse_tcx("/sdcard/some.tcx") is None + + def test_returns_none_when_file_not_written( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """adb pull succeeds but local file absent (race) → None.""" + locker = create_locker(mock_tk, tmp_path) + # _run_adb returns True but does not actually write the file. + object.__setattr__(locker, "_run_adb", MagicMock(return_value=(True, ""))) + assert locker._pull_and_parse_tcx("/sdcard/some.tcx") is None + + def test_returns_parsed_data_on_success( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Successful pull + valid TCX → parsed activity dict.""" + locker = create_locker(mock_tk, tmp_path) + tcx_src = tmp_path / "source.tcx" + tcx_src.write_text(_TCX_RUNNING) + + def _fake_pull(args: list[str]) -> tuple[bool, str]: + if args[0] == "pull": + shutil.copy(str(tcx_src), args[2]) + return True, "" + return True, "" + + object.__setattr__(locker, "_run_adb", MagicMock(side_effect=_fake_pull)) + result = locker._pull_and_parse_tcx("/sdcard/activity.tcx") + assert result is not None + assert result["sport"] == 0 + assert result["duration_seconds"] == 2400 + + +# --------------------------------------------------------------------------- +# _verify_runnerup_via_files +# --------------------------------------------------------------------------- diff --git a/screen_locker/tests/test_sick_tracker.py b/screen_locker/tests/test_sick_tracker.py index 48b4416..706426b 100644 --- a/screen_locker/tests/test_sick_tracker.py +++ b/screen_locker/tests/test_sick_tracker.py @@ -6,37 +6,25 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import patch -import pytest - from screen_locker import _sick_tracker from screen_locker._constants import ( SICK_BUDGET_PER_7_DAYS, SICK_BUDGET_PER_30_DAYS, SICK_BUDGET_PER_90_DAYS, SICK_COMMITMENT_PENALTY_DAYS, - SICK_HISTORY_REVIEW_COUNT, - SICK_JUSTIFICATION_MIN_CHARS, SICK_LOCKOUT_MULTIPLIER_PER_RECENT, SICK_LOCKOUT_SECONDS, ) from screen_locker._sick_tracker import ( - JustificationDraft, SickHistory, - add_justification, add_sick_day, budget_summary, clear_one_debt, compute_lockout_seconds, count_in_window, - format_recent_justifications, - had_commitment_for_today, is_budget_exhausted, load_history, - mark_commitment_broken, - recent_justifications, - record_commitment_for_tomorrow, save_history, - validate_justification, ) if TYPE_CHECKING: @@ -50,10 +38,12 @@ class TestLoadHistory: """Tests for load_history.""" def test_returns_empty_when_file_missing(self) -> None: + """Returns empty when file missing.""" history = load_history() assert history == SickHistory() def test_reads_existing_file(self, tmp_path: Path) -> None: + """Reads existing file.""" target = tmp_path / "sick_history.json" target.write_text( '{"sick_days": ["2026-05-01"], "debt": 2,' @@ -70,12 +60,14 @@ class TestLoadHistory: assert history.justifications == [{"date": "2026-05-01"}] def test_returns_empty_on_corrupt_json(self, tmp_path: Path) -> None: + """Returns empty on corrupt JSON.""" target = tmp_path / "sick_history.json" target.write_text("not json") with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target): assert load_history() == SickHistory() def test_returns_empty_on_oserror(self, tmp_path: Path) -> None: + """Returns empty on oserror.""" target = tmp_path / "sick_history.json" target.write_text("{}") with ( @@ -89,6 +81,7 @@ class TestSaveHistory: """Tests for save_history.""" def test_persists_history(self, tmp_path: Path) -> None: + """Persists history.""" target = tmp_path / "sick_history.json" with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target): history = SickHistory(sick_days=["2026-05-01"], debt=1) @@ -97,6 +90,7 @@ class TestSaveHistory: assert reloaded == history def test_returns_false_on_oserror(self, tmp_path: Path) -> None: + """Returns false on oserror.""" target = tmp_path / "missing_dir" / "sick_history.json" with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target): assert save_history(SickHistory()) is False @@ -106,6 +100,7 @@ class TestCountInWindow: """Tests for count_in_window.""" def test_counts_only_within_window(self) -> None: + """Counts only within window.""" history = SickHistory( sick_days=[ "2026-05-09", # 1 day ago: in 7d, 30d, 90d @@ -119,14 +114,17 @@ class TestCountInWindow: assert count_in_window(history, 90, today=_TODAY) == 3 def test_skips_invalid_date_strings(self) -> None: + """Skips invalid date strings.""" history = SickHistory(sick_days=["bad-date", "2026-05-09"]) assert count_in_window(history, 7, today=_TODAY) == 1 def test_returns_zero_when_today_invalid(self) -> None: + """Returns zero when today invalid.""" history = SickHistory(sick_days=["2026-05-09"]) assert count_in_window(history, 7, today="bogus") == 0 def test_uses_today_default_when_none(self) -> None: + """Uses today default when none.""" history = SickHistory(sick_days=[]) assert count_in_window(history, 7) == 0 @@ -135,9 +133,11 @@ class TestIsBudgetExhausted: """Tests for is_budget_exhausted.""" def test_false_when_under_budget(self) -> None: + """False when under budget.""" assert is_budget_exhausted(SickHistory(), today=_TODAY) is False def test_true_when_weekly_exhausted(self) -> None: + """True when weekly exhausted.""" history = SickHistory( sick_days=["2026-05-09"] * SICK_BUDGET_PER_7_DAYS, ) @@ -145,6 +145,7 @@ class TestIsBudgetExhausted: def test_true_when_monthly_exhausted(self) -> None: # Spread far enough apart to all be in 30d but not 7d. + """True when monthly exhausted.""" history = SickHistory( sick_days=[ "2026-05-08", @@ -156,6 +157,7 @@ class TestIsBudgetExhausted: def test_true_when_quarterly_exhausted(self) -> None: # All in 90d but only 1 in 30d. + """True when quarterly exhausted.""" days = [ "2026-05-09", "2026-04-01", @@ -176,11 +178,13 @@ class TestComputeLockoutSeconds: """Tests for compute_lockout_seconds.""" def test_base_when_no_recent(self) -> None: + """Base when no recent.""" assert ( compute_lockout_seconds(SickHistory(), today=_TODAY) == SICK_LOCKOUT_SECONDS ) def test_doubles_per_recent(self) -> None: + """Doubles per recent.""" history = SickHistory(sick_days=["2026-05-09", "2026-04-20"]) recent = 2 # both within 30d expected = SICK_LOCKOUT_SECONDS * (SICK_LOCKOUT_MULTIPLIER_PER_RECENT**recent) @@ -191,6 +195,7 @@ class TestBudgetSummary: """Tests for budget_summary.""" def test_renders_all_windows_and_debt(self) -> None: + """Renders all windows and debt.""" history = SickHistory(sick_days=["2026-05-09"], debt=3) summary = budget_summary(history, today=_TODAY) assert "Sick:" in summary @@ -202,12 +207,14 @@ class TestAddSickDay: """Tests for add_sick_day.""" def test_adds_today_and_increments_debt(self) -> None: + """Adds today and increments debt.""" history = SickHistory() new_debt = add_sick_day(history, today=_TODAY) assert history.sick_days == [_TODAY] assert new_debt == 1 def test_idempotent_on_same_day(self) -> None: + """Idempotent on same day.""" history = SickHistory(sick_days=[_TODAY], debt=0) new_debt = add_sick_day(history, today=_TODAY) assert history.sick_days == [_TODAY] @@ -215,6 +222,7 @@ class TestAddSickDay: assert new_debt == 1 def test_double_penalty_when_commitment_broken(self) -> None: + """Double penalty when commitment broken.""" history = SickHistory(broken_commitments=[_TODAY]) new_debt = add_sick_day(history, today=_TODAY) assert new_debt == SICK_COMMITMENT_PENALTY_DAYS @@ -224,163 +232,12 @@ class TestClearOneDebt: """Tests for clear_one_debt.""" def test_decrements_when_positive(self) -> None: + """Decrements when positive.""" history = SickHistory(debt=2) assert clear_one_debt(history) == 1 assert history.debt == 1 def test_clamped_at_zero(self) -> None: + """Clamped at zero.""" history = SickHistory(debt=0) assert clear_one_debt(history) == 0 - - -class TestRecordCommitment: - """Tests for record_commitment_for_tomorrow + had_commitment_for_today.""" - - def test_records_for_tomorrow(self) -> None: - history = SickHistory() - result = record_commitment_for_tomorrow(history, today=_TODAY) - assert result == "2026-05-11" - assert history.commitments["2026-05-11"] is True - - def test_returns_today_when_today_invalid(self) -> None: - history = SickHistory() - result = record_commitment_for_tomorrow(history, today="bogus") - assert result == "bogus" - assert history.commitments == {} - - def test_had_commitment_returns_true(self) -> None: - history = SickHistory(commitments={_TODAY: True}) - assert had_commitment_for_today(history, today=_TODAY) is True - - def test_had_commitment_returns_false(self) -> None: - assert had_commitment_for_today(SickHistory(), today=_TODAY) is False - - -class TestMarkCommitmentBroken: - """Tests for mark_commitment_broken.""" - - def test_appends_when_committed(self) -> None: - history = SickHistory(commitments={_TODAY: True}) - mark_commitment_broken(history, today=_TODAY) - assert history.broken_commitments == [_TODAY] - - def test_idempotent(self) -> None: - history = SickHistory(commitments={_TODAY: True}, broken_commitments=[_TODAY]) - mark_commitment_broken(history, today=_TODAY) - assert history.broken_commitments == [_TODAY] - - def test_noop_when_no_commitment(self) -> None: - history = SickHistory() - mark_commitment_broken(history, today=_TODAY) - assert history.broken_commitments == [] - - -class TestValidateJustification: - """Tests for validate_justification.""" - - def _good_text(self) -> str: - return "x" * SICK_JUSTIFICATION_MIN_CHARS - - def _draft( - self, - *, - symptom: str | None = None, - onset: str | None = None, - severity: int | None = None, - text: str | None = None, - ) -> JustificationDraft: - return JustificationDraft( - symptom="fever" if symptom is None else symptom, - onset="last night" if onset is None else onset, - severity=7 if severity is None else severity, - text=self._good_text() if text is None else text, - ) - - def test_returns_none_when_valid(self) -> None: - assert validate_justification(self._draft()) is None - - def test_rejects_blank_symptom(self) -> None: - assert validate_justification(self._draft(symptom=" ")) is not None - - def test_rejects_blank_onset(self) -> None: - assert validate_justification(self._draft(onset="")) is not None - - @pytest.mark.parametrize("severity", [0, 11, -1]) - def test_rejects_severity_out_of_range(self, severity: int) -> None: - assert validate_justification(self._draft(severity=severity)) is not None - - def test_rejects_short_text(self) -> None: - assert validate_justification(self._draft(text="too short")) is not None - - -class TestAddJustification: - """Tests for add_justification.""" - - def _draft(self, text: str = " full description text ") -> JustificationDraft: - return JustificationDraft( - symptom="fever", - onset="last night", - severity=7, - text=text, - ) - - def test_appends_entry_with_hmac_when_key_present(self) -> None: - history = SickHistory() - with patch.object(_sick_tracker, "compute_entry_hmac", return_value="deadbeef"): - entry = add_justification(history, self._draft(), today=_TODAY) - assert history.justifications == [entry] - assert entry["hmac"] == "deadbeef" - assert entry["text"] == "full description text" - assert entry["symptom"] == "fever" - assert entry["severity"] == 7 - assert entry["date"] == _TODAY - - def test_omits_hmac_when_key_unavailable(self) -> None: - history = SickHistory() - with patch.object(_sick_tracker, "compute_entry_hmac", return_value=None): - entry = add_justification( - history, - self._draft(text="full description"), - today=_TODAY, - ) - assert "hmac" not in entry - - -class TestRecentJustifications: - """Tests for recent_justifications + format_recent_justifications.""" - - def test_returns_last_n(self) -> None: - history = SickHistory( - justifications=[{"i": i} for i in range(5)], - ) - assert recent_justifications(history, 2) == [{"i": 3}, {"i": 4}] - - def test_returns_empty_list_when_n_zero(self) -> None: - history = SickHistory(justifications=[{"i": 0}]) - assert recent_justifications(history, 0) == [] - - def test_default_n_is_review_count(self) -> None: - history = SickHistory( - justifications=[{"i": i} for i in range(SICK_HISTORY_REVIEW_COUNT + 5)], - ) - assert len(recent_justifications(history)) == SICK_HISTORY_REVIEW_COUNT - - def test_format_returns_empty_when_no_history(self) -> None: - assert format_recent_justifications(SickHistory()) == "" - - def test_format_renders_lines(self) -> None: - history = SickHistory( - justifications=[ - {"date": "2026-05-01", "symptom": "fever", "severity": 7}, - {"date": "2026-04-15", "symptom": "headache", "severity": 4}, - ], - ) - out = format_recent_justifications(history) - assert "2026-05-01" in out - assert "fever" in out - assert "headache" in out - - def test_format_handles_missing_fields(self) -> None: - history = SickHistory(justifications=[{}]) - out = format_recent_justifications(history) - assert "?" in out diff --git a/screen_locker/tests/test_sick_tracker_part2.py b/screen_locker/tests/test_sick_tracker_part2.py new file mode 100644 index 0000000..62f533f --- /dev/null +++ b/screen_locker/tests/test_sick_tracker_part2.py @@ -0,0 +1,200 @@ +"""Tests for the sick-day tracker pure-logic module.""" +# pylint: disable=protected-access + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from screen_locker import _sick_tracker +from screen_locker._constants import ( + SICK_HISTORY_REVIEW_COUNT, + SICK_JUSTIFICATION_MIN_CHARS, +) +from screen_locker._sick_tracker import ( + JustificationDraft, + SickHistory, + add_justification, + format_recent_justifications, + had_commitment_for_today, + mark_commitment_broken, + recent_justifications, + record_commitment_for_tomorrow, + validate_justification, +) + +_TODAY = "2026-05-10" + + +class TestRecordCommitment: + """Tests for record_commitment_for_tomorrow + had_commitment_for_today.""" + + def test_records_for_tomorrow(self) -> None: + """Records for tomorrow.""" + history = SickHistory() + result = record_commitment_for_tomorrow(history, today=_TODAY) + assert result == "2026-05-11" + assert history.commitments["2026-05-11"] is True + + def test_returns_today_when_today_invalid(self) -> None: + """Returns today when today invalid.""" + history = SickHistory() + result = record_commitment_for_tomorrow(history, today="bogus") + assert result == "bogus" + assert history.commitments == {} + + def test_had_commitment_returns_true(self) -> None: + """Had commitment returns true.""" + history = SickHistory(commitments={_TODAY: True}) + assert had_commitment_for_today(history, today=_TODAY) is True + + def test_had_commitment_returns_false(self) -> None: + """Had commitment returns false.""" + assert had_commitment_for_today(SickHistory(), today=_TODAY) is False + + +class TestMarkCommitmentBroken: + """Tests for mark_commitment_broken.""" + + def test_appends_when_committed(self) -> None: + """Appends when committed.""" + history = SickHistory(commitments={_TODAY: True}) + mark_commitment_broken(history, today=_TODAY) + assert history.broken_commitments == [_TODAY] + + def test_idempotent(self) -> None: + """Idempotent.""" + history = SickHistory(commitments={_TODAY: True}, broken_commitments=[_TODAY]) + mark_commitment_broken(history, today=_TODAY) + assert history.broken_commitments == [_TODAY] + + def test_noop_when_no_commitment(self) -> None: + """Noop when no commitment.""" + history = SickHistory() + mark_commitment_broken(history, today=_TODAY) + assert history.broken_commitments == [] + + +class TestValidateJustification: + """Tests for validate_justification.""" + + def _good_text(self) -> str: + return "x" * SICK_JUSTIFICATION_MIN_CHARS + + def _draft( + self, + *, + symptom: str | None = None, + onset: str | None = None, + severity: int | None = None, + text: str | None = None, + ) -> JustificationDraft: + return JustificationDraft( + symptom="fever" if symptom is None else symptom, + onset="last night" if onset is None else onset, + severity=7 if severity is None else severity, + text=self._good_text() if text is None else text, + ) + + def test_returns_none_when_valid(self) -> None: + """Returns none when valid.""" + assert validate_justification(self._draft()) is None + + def test_rejects_blank_symptom(self) -> None: + """Rejects blank symptom.""" + assert validate_justification(self._draft(symptom=" ")) is not None + + def test_rejects_blank_onset(self) -> None: + """Rejects blank onset.""" + assert validate_justification(self._draft(onset="")) is not None + + @pytest.mark.parametrize("severity", [0, 11, -1]) + def test_rejects_severity_out_of_range(self, severity: int) -> None: + """Rejects severity out of range.""" + assert validate_justification(self._draft(severity=severity)) is not None + + def test_rejects_short_text(self) -> None: + """Rejects short text.""" + assert validate_justification(self._draft(text="too short")) is not None + + +class TestAddJustification: + """Tests for add_justification.""" + + def _draft(self, text: str = " full description text ") -> JustificationDraft: + return JustificationDraft( + symptom="fever", + onset="last night", + severity=7, + text=text, + ) + + def test_appends_entry_with_hmac_when_key_present(self) -> None: + """Appends entry with HMAC when key present.""" + history = SickHistory() + with patch.object(_sick_tracker, "compute_entry_hmac", return_value="deadbeef"): + entry = add_justification(history, self._draft(), today=_TODAY) + assert history.justifications == [entry] + assert entry["hmac"] == "deadbeef" + assert entry["text"] == "full description text" + assert entry["symptom"] == "fever" + assert entry["severity"] == 7 + assert entry["date"] == _TODAY + + def test_omits_hmac_when_key_unavailable(self) -> None: + """Omits HMAC when key unavailable.""" + history = SickHistory() + with patch.object(_sick_tracker, "compute_entry_hmac", return_value=None): + entry = add_justification( + history, + self._draft(text="full description"), + today=_TODAY, + ) + assert "hmac" not in entry + + +class TestRecentJustifications: + """Tests for recent_justifications + format_recent_justifications.""" + + def test_returns_last_n(self) -> None: + """Returns last n.""" + history = SickHistory( + justifications=[{"i": i} for i in range(5)], + ) + assert recent_justifications(history, 2) == [{"i": 3}, {"i": 4}] + + def test_returns_empty_list_when_n_zero(self) -> None: + """Returns empty list when n zero.""" + history = SickHistory(justifications=[{"i": 0}]) + assert recent_justifications(history, 0) == [] + + def test_default_n_is_review_count(self) -> None: + """Default n is review count.""" + history = SickHistory( + justifications=[{"i": i} for i in range(SICK_HISTORY_REVIEW_COUNT + 5)], + ) + assert len(recent_justifications(history)) == SICK_HISTORY_REVIEW_COUNT + + def test_format_returns_empty_when_no_history(self) -> None: + """Format returns empty when no history.""" + assert format_recent_justifications(SickHistory()) == "" + + def test_format_renders_lines(self) -> None: + """Format renders lines.""" + history = SickHistory( + justifications=[ + {"date": "2026-05-01", "symptom": "fever", "severity": 7}, + {"date": "2026-04-15", "symptom": "headache", "severity": 4}, + ], + ) + out = format_recent_justifications(history) + assert "2026-05-01" in out + assert "fever" in out + assert "headache" in out + + def test_format_handles_missing_fields(self) -> None: + """Format handles missing fields.""" + history = SickHistory(justifications=[{}]) + out = format_recent_justifications(history) + assert "?" in out diff --git a/screen_locker/tests/test_status_data.py b/screen_locker/tests/test_status_data.py index 08bc185..166b8d5 100644 --- a/screen_locker/tests/test_status_data.py +++ b/screen_locker/tests/test_status_data.py @@ -2,12 +2,8 @@ from __future__ import annotations -from datetime import date, datetime, timezone -import json +from datetime import datetime, timezone from typing import TYPE_CHECKING -from unittest.mock import patch - -from screen_locker._status_data import _day_status, format_summary_line, gather_status if TYPE_CHECKING: from pathlib import Path @@ -29,371 +25,3 @@ def _files(tmp_path: Path) -> dict[str, Path]: "scheduled_skips_file": tmp_path / "scheduled_skips.json", "early_bird_pending_file": tmp_path / "early_bird_pending.json", } - - -class TestGatherStatus: - """gather_status() end-to-end via tmp_path-backed state files.""" - - def test_empty_state_full_lock_by_default(self, tmp_path: Path) -> None: - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**_files(tmp_path), now=_FRIDAY_NOON_UTC) - - assert snap.today.date == "2024-01-05" - assert snap.today.entry_types == () - assert snap.today.day_count == 0 - assert snap.today.counted is False - assert snap.today.is_sick_day is False - assert snap.week.counted_count == 0 - assert snap.week.remaining == 5 - assert snap.week.extra == 0 - assert snap.shutdown.tonight is None - assert snap.shutdown.rest_of_week[0].hour == 21 - assert snap.shutdown.rest_of_week[0].speculative is False - assert snap.sick_budget.used_7d == 0 - assert snap.sick_budget.exhausted is False - assert snap.streak == 0 - assert snap.early_bird_extended is False - assert snap.lock_explanation.fired is True - assert snap.lock_explanation.stage == "full_lock_pending_heat_check" - - def test_populated_week_counts_workouts(self, tmp_path: Path) -> None: - files = _files(tmp_path) - files["log_file"].write_text( - json.dumps( - { - "2024-01-01": { - "workout_data": { - "type": "heat_skip", - "temperature_celsius": "34", - } - }, - "2024-01-03": { - "workout_data": {"type": "runnerup_verified", "source": "run"} - }, - "2024-01-05": { - "workout_data": {"type": "phone_verified", "source": "gym"} - }, - } - ) - ) - with ( - patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ), - patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=True - ), - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert snap.week.counted_count == 2 - assert snap.today.entry_types == ("phone_verified",) - assert snap.today.day_count == 1 - assert snap.today.source == "gym" - assert snap.today.counted is True - assert snap.lock_explanation.fired is False - assert snap.lock_explanation.stage == "already_logged" - - def test_sick_day_marks_today_and_skips_lock(self, tmp_path: Path) -> None: - files = _files(tmp_path) - (tmp_path / "sick_history.json").write_text( - json.dumps({"sick_days": ["2024-01-05"], "debt": 1}) - ) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert snap.today.is_sick_day is True - assert snap.sick_budget.used_7d == 1 - assert snap.sick_budget.debt == 1 - assert snap.lock_explanation.fired is False - assert snap.lock_explanation.stage == "sick_day" - - def test_sick_budget_exhausted_flag(self, tmp_path: Path) -> None: - files = _files(tmp_path) - (tmp_path / "sick_history.json").write_text( - json.dumps({"sick_days": ["2024-01-05"], "debt": 0}) - ) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert snap.sick_budget.exhausted is True - - def test_wake_alarm_skip_stops_lock(self, tmp_path: Path) -> None: - files = _files(tmp_path) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=True - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert snap.lock_explanation.fired is False - assert snap.lock_explanation.stage == "wake_alarm_skip" - - def test_scheduled_skip_stops_lock(self, tmp_path: Path) -> None: - files = _files(tmp_path) - files["scheduled_skips_file"].write_text(json.dumps(["2024-01-05"])) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert snap.lock_explanation.stage == "scheduled_skip" - - def test_relaxed_day_stops_lock(self, tmp_path: Path) -> None: - """Tuesday is a relaxed day — is_relaxed_day derives it from `now`.""" - files = _files(tmp_path) - tuesday_noon = datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=tuesday_noon) - - assert snap.lock_explanation.stage == "relaxed_day" - - def test_weekly_minimum_met_stops_lock(self, tmp_path: Path) -> None: - # Avoid dating anything "today" (2024-01-05): an unsigned today entry - # trips already_logged before weekly_minimum_met, and does so only - # when no HMAC key is configured (env-dependent) -- Jan 4 holds two. - files = _files(tmp_path) - files["log_file"].write_text( - json.dumps( - { - "2024-01-01": {"workout_data": {"type": "phone_verified"}}, - "2024-01-02": {"workout_data": {"type": "phone_verified"}}, - "2024-01-03": {"workout_data": {"type": "phone_verified"}}, - "2024-01-04": [ - {"workout_data": {"type": "phone_verified"}}, - {"workout_data": {"type": "phone_verified"}}, - ], - } - ) - ) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert snap.week.counted_count == 5 - assert snap.week.remaining == 0 - assert snap.week.extra == 0 - assert snap.lock_explanation.stage == "weekly_minimum_met" - - def test_extra_workouts_beyond_minimum(self, tmp_path: Path) -> None: - files = _files(tmp_path) - # Jan 1-4 one each, Jan 5 (today, Friday — the last day in-week without - # going into the future) holds two, for 6 counted total: 1 above the - # new minimum of 5. Also exercises multiple same-day entries each - # counting individually. - files["log_file"].write_text( - json.dumps( - { - "2024-01-01": {"workout_data": {"type": "phone_verified"}}, - "2024-01-02": {"workout_data": {"type": "phone_verified"}}, - "2024-01-03": {"workout_data": {"type": "phone_verified"}}, - "2024-01-04": {"workout_data": {"type": "phone_verified"}}, - "2024-01-05": [ - {"workout_data": {"type": "phone_verified"}}, - {"workout_data": {"type": "phone_verified"}}, - ], - } - ) - ) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert snap.week.extra == 1 - assert snap.week.remaining == 0 - - def test_shutdown_config_present_reflected_in_tonight(self, tmp_path: Path) -> None: - files = _files(tmp_path) - files["shutdown_config_file"].write_text( - "MON_WED_HOUR=22\nTHU_SUN_HOUR=23\nMORNING_END_HOUR=5\n" - ) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert snap.shutdown.tonight == (22, 23, 5) - - def test_bonus_streak_and_extended_early_bird_reflected( - self, tmp_path: Path - ) -> None: - files = _files(tmp_path) - files["extra_benefits_file"].write_text( - json.dumps( - { - "consecutive_5plus_weeks": 2, - "weekly_shutdown_bonus_hours": {"2024-W01": 3}, - "extended_early_bird_iso_weeks": ["2024-W01"], - } - ) - ) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert snap.bonus_hours_this_week == 3 - assert snap.streak == 2 - assert snap.early_bird_extended is True - assert snap.shutdown.rest_of_week[0].hour == 24 # 21 base + 3 bonus - - def test_next_week_preview_is_speculative_rest_of_week_is_not( - self, tmp_path: Path - ) -> None: - files = _files(tmp_path) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert all(d.speculative is False for d in snap.shutdown.rest_of_week) - assert all(d.speculative is True for d in snap.shutdown.next_week_preview) - assert len(snap.shutdown.rest_of_week) == 7 - assert len(snap.shutdown.next_week_preview) == 7 - - def test_corrupt_log_file_treated_as_empty(self, tmp_path: Path) -> None: - files = _files(tmp_path) - files["log_file"].write_text("{not valid json") - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert snap.week.counted_count == 0 - assert snap.today.entry_types == () - - def test_now_defaults_to_current_time_when_omitted(self, tmp_path: Path) -> None: - """Covers the ``now is None`` branch — just needs to not raise.""" - files = _files(tmp_path) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files) - - assert isinstance(snap.generated_at, str) - datetime.fromisoformat(snap.generated_at) # must parse without raising - - -class TestDayStatus: - """Direct tests for the _day_status helper on a day's entry list.""" - - def test_no_entries_not_sick(self) -> None: - day = _day_status(date(2024, 1, 5), [], set()) - assert day.entry_types == () - assert day.counted is False - assert day.day_count == 0 - assert day.is_sick_day is False - - def test_no_entries_but_sick(self) -> None: - day = _day_status(date(2024, 1, 5), [], {"2024-01-05"}) - assert day.is_sick_day is True - - def test_non_dict_entries_dropped(self) -> None: - """Corrupt log data (non-dict entries in the list) are skipped.""" - day = _day_status(date(2024, 1, 5), ["corrupt-string-entry"], set()) - assert day.entry_types == () - assert day.counted is False - assert day.day_count == 0 - assert day.source == "" - - def test_multiple_entries_count_verified_individually_manual_once(self) -> None: - """Two verified + two manual on one day → day_count 3, all types listed.""" - day = _day_status( - date(2024, 1, 5), - [ - {"workout_data": {"type": "runnerup_verified", "source": "run"}}, - {"workout_data": {"type": "phone_verified", "source": "gym"}}, - {"workout_data": {"type": "manual_workout", "source": "tt"}}, - {"workout_data": {"type": "manual_workout", "source": "squash"}}, - ], - set(), - ) - assert day.entry_types == ( - "runnerup_verified", - "phone_verified", - "manual_workout", - "manual_workout", - ) - assert day.counted is True - # 2 verified counted individually + all manual entries count once = 3. - assert day.day_count == 3 - assert day.source == "run · gym · tt · squash" - - -class TestFormatSummaryLine: - """format_summary_line variants.""" - - def test_checkmark_when_week_complete(self, tmp_path: Path) -> None: - files = _files(tmp_path) - files["log_file"].write_text( - json.dumps( - { - d: {"workout_data": {"type": "phone_verified"}} - for d in ( - "2024-01-01", - "2024-01-02", - "2024-01-03", - "2024-01-04", - "2024-01-05", - ) - } - ) - ) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert format_summary_line(snap).startswith("✓") - - def test_ellipsis_when_week_incomplete(self, tmp_path: Path) -> None: - files = _files(tmp_path) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert format_summary_line(snap).startswith("…") - - def test_question_mark_when_no_shutdown_config(self, tmp_path: Path) -> None: - files = _files(tmp_path) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert "?:00 tonight" not in format_summary_line(snap) - assert "? tonight" in format_summary_line(snap) - - def test_mon_wed_band_used_on_monday(self, tmp_path: Path) -> None: - files = _files(tmp_path) - files["shutdown_config_file"].write_text( - "MON_WED_HOUR=20\nTHU_SUN_HOUR=23\nMORNING_END_HOUR=5\n" - ) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_MONDAY_NOON_UTC) - - assert "20:00 tonight" in format_summary_line(snap) - - def test_thu_sun_band_used_on_friday(self, tmp_path: Path) -> None: - files = _files(tmp_path) - files["shutdown_config_file"].write_text( - "MON_WED_HOUR=20\nTHU_SUN_HOUR=23\nMORNING_END_HOUR=5\n" - ) - with patch( - "screen_locker._status_data.has_workout_skip_today", return_value=False - ): - snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - - assert "23:00 tonight" in format_summary_line(snap) diff --git a/screen_locker/tests/test_status_data_part2.py b/screen_locker/tests/test_status_data_part2.py new file mode 100644 index 0000000..c06045a --- /dev/null +++ b/screen_locker/tests/test_status_data_part2.py @@ -0,0 +1,212 @@ +"""Tests for the read-only status snapshot layer in _status_data.py.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from screen_locker._status_data import _day_status, gather_status +from screen_locker.tests.test_status_data import _files + +if TYPE_CHECKING: + from pathlib import Path + +# Fixed reference instant: Friday 2024-01-05, 12:00 UTC == 13:00 Europe/Warsaw. +# Outside the 05:00-09:00 early-bird window and not a Tue/Wed/Thu relaxed day, +# so lock-decision branches are fully deterministic regardless of wall clock. +_FRIDAY_NOON_UTC = datetime(2024, 1, 5, 12, 0, tzinfo=timezone.utc) +# Monday of that same ISO week, for Mon-Wed shutdown-band assertions. +_MONDAY_NOON_UTC = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc) + + +class TestGatherStatus: + """gather_status() end-to-end via tmp_path-backed state files.""" + + def test_empty_state_full_lock_by_default(self, tmp_path: Path) -> None: + """Empty state full lock by default.""" + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**_files(tmp_path), now=_FRIDAY_NOON_UTC) + + assert snap.today.date == "2024-01-05" + assert snap.today.entry_types == () + assert snap.today.day_count == 0 + assert snap.today.counted is False + assert snap.today.is_sick_day is False + assert snap.week.counted_count == 0 + assert snap.week.remaining == 5 + assert snap.week.extra == 0 + assert snap.shutdown.tonight is None + assert snap.shutdown.rest_of_week[0].hour == 21 + assert snap.shutdown.rest_of_week[0].speculative is False + assert snap.sick_budget.used_7d == 0 + assert snap.sick_budget.exhausted is False + assert snap.streak == 0 + assert snap.early_bird_extended is False + assert snap.lock_explanation.fired is True + assert snap.lock_explanation.stage == "full_lock_pending_heat_check" + + def test_populated_week_counts_workouts(self, tmp_path: Path) -> None: + """Populated week counts workouts.""" + files = _files(tmp_path) + files["log_file"].write_text( + json.dumps( + { + "2024-01-01": { + "workout_data": { + "type": "heat_skip", + "temperature_celsius": "34", + } + }, + "2024-01-03": { + "workout_data": {"type": "runnerup_verified", "source": "run"} + }, + "2024-01-05": { + "workout_data": {"type": "phone_verified", "source": "gym"} + }, + } + ) + ) + with ( + patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ), + patch( + "screen_locker._compliance_state.verify_entry_hmac", return_value=True + ), + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.week.counted_count == 2 + assert snap.today.entry_types == ("phone_verified",) + assert snap.today.day_count == 1 + assert snap.today.source == "gym" + assert snap.today.counted is True + assert snap.lock_explanation.fired is False + assert snap.lock_explanation.stage == "already_logged" + + def test_shutdown_config_present_reflected_in_tonight(self, tmp_path: Path) -> None: + """Shutdown config present reflected in tonight.""" + files = _files(tmp_path) + files["shutdown_config_file"].write_text( + "MON_WED_HOUR=22\nTHU_SUN_HOUR=23\nMORNING_END_HOUR=5\n" + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.shutdown.tonight == (22, 23, 5) + + def test_bonus_streak_and_extended_early_bird_reflected( + self, tmp_path: Path + ) -> None: + """Bonus streak and extended early bird reflected.""" + files = _files(tmp_path) + files["extra_benefits_file"].write_text( + json.dumps( + { + "consecutive_5plus_weeks": 2, + "weekly_shutdown_bonus_hours": {"2024-W01": 3}, + "extended_early_bird_iso_weeks": ["2024-W01"], + } + ) + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.bonus_hours_this_week == 3 + assert snap.streak == 2 + assert snap.early_bird_extended is True + assert snap.shutdown.rest_of_week[0].hour == 24 # 21 base + 3 bonus + + def test_next_week_preview_is_speculative_rest_of_week_is_not( + self, tmp_path: Path + ) -> None: + """Next week preview is speculative rest of week is not.""" + files = _files(tmp_path) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert all(d.speculative is False for d in snap.shutdown.rest_of_week) + assert all(d.speculative is True for d in snap.shutdown.next_week_preview) + assert len(snap.shutdown.rest_of_week) == 7 + assert len(snap.shutdown.next_week_preview) == 7 + + def test_corrupt_log_file_treated_as_empty(self, tmp_path: Path) -> None: + """Corrupt log file treated as empty.""" + files = _files(tmp_path) + files["log_file"].write_text("{not valid json") + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.week.counted_count == 0 + assert snap.today.entry_types == () + + def test_now_defaults_to_current_time_when_omitted(self, tmp_path: Path) -> None: + """Covers the ``now is None`` branch — just needs to not raise.""" + files = _files(tmp_path) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files) + + assert isinstance(snap.generated_at, str) + datetime.fromisoformat(snap.generated_at) # must parse without raising + + +class TestDayStatus: + """Direct tests for the _day_status helper on a day's entry list.""" + + def test_no_entries_not_sick(self) -> None: + """No entries not sick.""" + day = _day_status(date(2024, 1, 5), [], set()) + assert day.entry_types == () + assert day.counted is False + assert day.day_count == 0 + assert day.is_sick_day is False + + def test_no_entries_but_sick(self) -> None: + """No entries but sick.""" + day = _day_status(date(2024, 1, 5), [], {"2024-01-05"}) + assert day.is_sick_day is True + + def test_non_dict_entries_dropped(self) -> None: + """Corrupt log data (non-dict entries in the list) are skipped.""" + day = _day_status(date(2024, 1, 5), ["corrupt-string-entry"], set()) + assert day.entry_types == () + assert day.counted is False + assert day.day_count == 0 + assert day.source == "" + + def test_multiple_entries_count_verified_individually_manual_once(self) -> None: + """Two verified + two manual on one day → day_count 3, all types listed.""" + day = _day_status( + date(2024, 1, 5), + [ + {"workout_data": {"type": "runnerup_verified", "source": "run"}}, + {"workout_data": {"type": "phone_verified", "source": "gym"}}, + {"workout_data": {"type": "manual_workout", "source": "tt"}}, + {"workout_data": {"type": "manual_workout", "source": "squash"}}, + ], + set(), + ) + assert day.entry_types == ( + "runnerup_verified", + "phone_verified", + "manual_workout", + "manual_workout", + ) + assert day.counted is True + # 2 verified counted individually + all manual entries count once = 3. + assert day.day_count == 3 + assert day.source == "run · gym · tt · squash" diff --git a/screen_locker/tests/test_status_data_part3.py b/screen_locker/tests/test_status_data_part3.py new file mode 100644 index 0000000..146e963 --- /dev/null +++ b/screen_locker/tests/test_status_data_part3.py @@ -0,0 +1,151 @@ +"""Tests for gather_status's lock-suppression paths. + +Split out of test_status_data_part2.py for the 250-line cap: these are the +cases where something (sick day, wake alarm, scheduled skip, relaxed day, +weekly minimum) means no lock is needed. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from screen_locker._status_data import gather_status +from screen_locker.tests.test_status_data import _files + +if TYPE_CHECKING: + from pathlib import Path + +# Fixed reference instant: Friday 2024-01-05, 12:00 UTC == 13:00 Europe/Warsaw. +# Outside the 05:00-09:00 early-bird window and not a Tue/Wed/Thu relaxed day, +# so lock-decision branches are fully deterministic regardless of wall clock. +_FRIDAY_NOON_UTC = datetime(2024, 1, 5, 12, 0, tzinfo=timezone.utc) +# Monday of that same ISO week, for Mon-Wed shutdown-band assertions. +_MONDAY_NOON_UTC = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc) + + +class TestGatherStatusLockSuppression: + """Conditions under which gather_status reports no lock is needed.""" + + def test_sick_day_marks_today_and_skips_lock(self, tmp_path: Path) -> None: + """Sick day marks today and skips lock.""" + files = _files(tmp_path) + (tmp_path / "sick_history.json").write_text( + json.dumps({"sick_days": ["2024-01-05"], "debt": 1}) + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.today.is_sick_day is True + assert snap.sick_budget.used_7d == 1 + assert snap.sick_budget.debt == 1 + assert snap.lock_explanation.fired is False + assert snap.lock_explanation.stage == "sick_day" + + def test_sick_budget_exhausted_flag(self, tmp_path: Path) -> None: + """Sick budget exhausted flag.""" + files = _files(tmp_path) + (tmp_path / "sick_history.json").write_text( + json.dumps({"sick_days": ["2024-01-05"], "debt": 0}) + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.sick_budget.exhausted is True + + def test_wake_alarm_skip_stops_lock(self, tmp_path: Path) -> None: + """Wake alarm skip stops lock.""" + files = _files(tmp_path) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=True + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.lock_explanation.fired is False + assert snap.lock_explanation.stage == "wake_alarm_skip" + + def test_scheduled_skip_stops_lock(self, tmp_path: Path) -> None: + """Scheduled skip stops lock.""" + files = _files(tmp_path) + files["scheduled_skips_file"].write_text(json.dumps(["2024-01-05"])) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.lock_explanation.stage == "scheduled_skip" + + def test_relaxed_day_stops_lock(self, tmp_path: Path) -> None: + """Tuesday is a relaxed day — is_relaxed_day derives it from `now`.""" + files = _files(tmp_path) + tuesday_noon = datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=tuesday_noon) + + assert snap.lock_explanation.stage == "relaxed_day" + + def test_weekly_minimum_met_stops_lock(self, tmp_path: Path) -> None: + # Avoid dating anything "today" (2024-01-05): an unsigned today entry + # trips already_logged before weekly_minimum_met, and does so only + # when no HMAC key is configured (env-dependent) -- Jan 4 holds two. + """Weekly minimum met stops lock.""" + files = _files(tmp_path) + files["log_file"].write_text( + json.dumps( + { + "2024-01-01": {"workout_data": {"type": "phone_verified"}}, + "2024-01-02": {"workout_data": {"type": "phone_verified"}}, + "2024-01-03": {"workout_data": {"type": "phone_verified"}}, + "2024-01-04": [ + {"workout_data": {"type": "phone_verified"}}, + {"workout_data": {"type": "phone_verified"}}, + ], + } + ) + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.week.counted_count == 5 + assert snap.week.remaining == 0 + assert snap.week.extra == 0 + assert snap.lock_explanation.stage == "weekly_minimum_met" + + def test_extra_workouts_beyond_minimum(self, tmp_path: Path) -> None: + """Extra workouts beyond minimum.""" + files = _files(tmp_path) + # Jan 1-4 one each, Jan 5 (today, Friday — the last day in-week without + # going into the future) holds two, for 6 counted total: 1 above the + # new minimum of 5. Also exercises multiple same-day entries each + # counting individually. + files["log_file"].write_text( + json.dumps( + { + "2024-01-01": {"workout_data": {"type": "phone_verified"}}, + "2024-01-02": {"workout_data": {"type": "phone_verified"}}, + "2024-01-03": {"workout_data": {"type": "phone_verified"}}, + "2024-01-04": {"workout_data": {"type": "phone_verified"}}, + "2024-01-05": [ + {"workout_data": {"type": "phone_verified"}}, + {"workout_data": {"type": "phone_verified"}}, + ], + } + ) + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert snap.week.extra == 1 + assert snap.week.remaining == 0 diff --git a/screen_locker/tests/test_status_data_part4.py b/screen_locker/tests/test_status_data_part4.py new file mode 100644 index 0000000..e7fb0cf --- /dev/null +++ b/screen_locker/tests/test_status_data_part4.py @@ -0,0 +1,96 @@ +"""Tests for the read-only status snapshot layer in _status_data.py.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from screen_locker._status_data import format_summary_line, gather_status +from screen_locker.tests.test_status_data import _files + +if TYPE_CHECKING: + from pathlib import Path + +# Fixed reference instant: Friday 2024-01-05, 12:00 UTC == 13:00 Europe/Warsaw. +# Outside the 05:00-09:00 early-bird window and not a Tue/Wed/Thu relaxed day, +# so lock-decision branches are fully deterministic regardless of wall clock. +_FRIDAY_NOON_UTC = datetime(2024, 1, 5, 12, 0, tzinfo=timezone.utc) +# Monday of that same ISO week, for Mon-Wed shutdown-band assertions. +_MONDAY_NOON_UTC = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc) + + +class TestFormatSummaryLine: + """format_summary_line variants.""" + + def test_checkmark_when_week_complete(self, tmp_path: Path) -> None: + """Checkmark when week complete.""" + files = _files(tmp_path) + files["log_file"].write_text( + json.dumps( + { + d: {"workout_data": {"type": "phone_verified"}} + for d in ( + "2024-01-01", + "2024-01-02", + "2024-01-03", + "2024-01-04", + "2024-01-05", + ) + } + ) + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert format_summary_line(snap).startswith("✓") + + def test_ellipsis_when_week_incomplete(self, tmp_path: Path) -> None: + """Ellipsis when week incomplete.""" + files = _files(tmp_path) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert format_summary_line(snap).startswith("…") + + def test_question_mark_when_no_shutdown_config(self, tmp_path: Path) -> None: + """Question mark when no shutdown config.""" + files = _files(tmp_path) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert "?:00 tonight" not in format_summary_line(snap) + assert "? tonight" in format_summary_line(snap) + + def test_mon_wed_band_used_on_monday(self, tmp_path: Path) -> None: + """Mon wed band used on monday.""" + files = _files(tmp_path) + files["shutdown_config_file"].write_text( + "MON_WED_HOUR=20\nTHU_SUN_HOUR=23\nMORNING_END_HOUR=5\n" + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_MONDAY_NOON_UTC) + + assert "20:00 tonight" in format_summary_line(snap) + + def test_thu_sun_band_used_on_friday(self, tmp_path: Path) -> None: + """Thu sun band used on friday.""" + files = _files(tmp_path) + files["shutdown_config_file"].write_text( + "MON_WED_HOUR=20\nTHU_SUN_HOUR=23\nMORNING_END_HOUR=5\n" + ) + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**files, now=_FRIDAY_NOON_UTC) + + assert "23:00 tonight" in format_summary_line(snap) diff --git a/screen_locker/tests/test_status_view_part2.py b/screen_locker/tests/test_status_view_part2.py index 6016e86..7aacd53 100644 --- a/screen_locker/tests/test_status_view_part2.py +++ b/screen_locker/tests/test_status_view_part2.py @@ -8,35 +8,33 @@ from unittest.mock import MagicMock, patch from screen_locker._status_view_verify import PhoneCheckOutcome from screen_locker._workout_credit import WorkoutCreditResult from screen_locker.status_view import ( - StatusWindow, _compliance_state_word, _make_bare_verifier, - main, ) from screen_locker.tests._status_view_helpers import ( _button_texts, _lock_explanation, _make_window, _snapshot, - _temp_check, _texts, _week, ) if TYPE_CHECKING: - from collections.abc import Callable from pathlib import Path - import pytest - class TestPhoneCheckResultRendering: + """PhoneCheckResultRendering.""" + def test_no_result_shows_nothing(self, mock_tk: MagicMock) -> None: + """No result shows nothing.""" snap = _snapshot() _make_window(mock_tk, snap) assert not any("Phone check" in t for t in _texts(mock_tk)) def test_verified_result_shown_in_green(self, mock_tk: MagicMock) -> None: + """Verified result shown in green.""" window = _make_window(mock_tk, _snapshot()) window._phone_check_result = ("verified", "Workout verified!") window.render(_snapshot()) @@ -49,6 +47,7 @@ class TestPhoneCheckResultRendering: assert calls[-1].kwargs.get("fg") == window._colors.success def test_non_verified_result_shown_in_orange(self, mock_tk: MagicMock) -> None: + """Non verified result shown in orange.""" window = _make_window(mock_tk, _snapshot()) window._phone_check_result = ("no_phone", "Phone not connected") window.render(_snapshot()) @@ -62,9 +61,12 @@ class TestPhoneCheckResultRendering: class TestButtons: + """Buttons.""" + def test_renders_check_phone_refresh_close( self, mock_tk: MagicMock, temp_log_file: Path ) -> None: + """Renders check phone refresh close.""" _make_window(mock_tk, _snapshot(), log_file=temp_log_file) assert _button_texts(mock_tk) == { "Check Phone", @@ -75,9 +77,12 @@ class TestButtons: class TestRefreshAndCheckPhoneFlow: + """RefreshAndCheckPhoneFlow.""" + def test_refresh_clears_phone_result_and_calls_on_refresh( self, mock_tk: MagicMock ) -> None: + """Refresh clears phone result and calls on refresh.""" on_refresh = MagicMock() window = _make_window(mock_tk, _snapshot(), on_refresh=on_refresh) window._phone_check_result = ("verified", "x") @@ -103,6 +108,7 @@ class TestRefreshAndCheckPhoneFlow: assert window._phone_future is not None def test_poll_waits_when_future_not_done(self, mock_tk: MagicMock) -> None: + """Poll waits when future not done.""" window = _make_window(mock_tk, _snapshot()) mock_future = MagicMock() mock_future.done.return_value = False @@ -113,6 +119,7 @@ class TestRefreshAndCheckPhoneFlow: window.root.after.assert_called_with(500, window._poll_phone_check) def test_poll_waits_when_future_is_none(self, mock_tk: MagicMock) -> None: + """Poll waits when future is none.""" window = _make_window(mock_tk, _snapshot()) window._phone_future = None with patch.object(window, "_on_phone_check_result") as mock_handle: @@ -212,7 +219,10 @@ class TestRefreshAndCheckPhoneFlow: class TestMakeBareVerifier: + """MakeBareVerifier.""" + def test_builds_locker_without_running_init(self, tmp_path: Path) -> None: + """Builds locker without running init.""" log_file = tmp_path / "workout_log.json" verifier = _make_bare_verifier(log_file) assert verifier.log_file == log_file @@ -220,11 +230,15 @@ class TestMakeBareVerifier: class TestComplianceStateWord: + """ComplianceStateWord.""" + def test_lock_when_fired(self) -> None: + """Lock when fired.""" snap = _snapshot(lock_explanation=_lock_explanation(fired=True)) assert _compliance_state_word(snap) == "lock" def test_warn_when_not_fired_but_remaining(self) -> None: + """Warn when not fired but remaining.""" snap = _snapshot( lock_explanation=_lock_explanation(fired=False), week=_week(remaining=2), @@ -232,158 +246,9 @@ class TestComplianceStateWord: assert _compliance_state_word(snap) == "warn" def test_ok_when_not_fired_and_minimum_met(self) -> None: + """Ok when not fired and minimum met.""" snap = _snapshot( lock_explanation=_lock_explanation(fired=False), week=_week(remaining=0), ) assert _compliance_state_word(snap) == "ok" - - -class TestMain: - def test_summary_flag_prints_and_returns( - self, capsys: pytest.CaptureFixture[str] - ) -> None: - with patch("screen_locker.status_view.gather_status", return_value=_snapshot()): - main(["--summary"]) - out = capsys.readouterr().out - assert "workouts" in out - - def test_state_flag_prints_and_returns( - self, capsys: pytest.CaptureFixture[str] - ) -> None: - with patch( - "screen_locker.status_view.gather_status", - return_value=_snapshot(lock_explanation=_lock_explanation(fired=True)), - ): - main(["--state"]) - out = capsys.readouterr().out - assert out.strip() == "lock" - - def test_no_flags_opens_window(self, mock_tk: MagicMock) -> None: - with patch("screen_locker.status_view.gather_status", return_value=_snapshot()): - main([]) - mock_tk.Tk.assert_called_once() - mock_tk.Tk.return_value.mainloop.assert_called_once() - - def test_no_flags_refresh_closure_regathers_and_rerenders( - self, mock_tk: MagicMock - ) -> None: - """Exercises main()'s inner refresh() closure passed as on_refresh.""" - captured: dict[str, Callable[[], None]] = {} - - class _CapturingWindow(StatusWindow): - def __init__( - self, - root: object, - snapshot: object, - *, - on_refresh: Callable[[], None], - **kwargs: object, - ) -> None: - captured["on_refresh"] = on_refresh - super().__init__(root, snapshot, on_refresh=on_refresh, **kwargs) - - with ( - patch( - "screen_locker.status_view.gather_status", return_value=_snapshot() - ) as mock_gather, - patch("screen_locker.status_view.StatusWindow", _CapturingWindow), - ): - main([]) - calls_before = mock_gather.call_count - captured["on_refresh"]() - - assert mock_gather.call_count == calls_before + 1 - - -class TestTemperatureSectionRendering: - def test_shows_checking_message_before_result(self, mock_tk: MagicMock) -> None: - window = _make_window(mock_tk, _snapshot()) - window._temp_result = None - window.render(_snapshot()) - assert any("Checking Warsaw temperature" in t for t in _texts(mock_tk)) - - def test_shows_resolved_temperature(self, mock_tk: MagicMock) -> None: - window = _make_window(mock_tk, _snapshot()) - window._temp_result = _temp_check(temp_celsius=22.0) - window.render(_snapshot()) - assert any("Warsaw: 22" in t for t in _texts(mock_tk)) - - def test_flags_would_trigger_heat_skip_when_hot(self, mock_tk: MagicMock) -> None: - window = _make_window(mock_tk, _snapshot()) - window._temp_result = _temp_check(temp_celsius=35.0) - window.render(_snapshot()) - assert any("Would trigger heat-skip today." in t for t in _texts(mock_tk)) - - def test_does_not_flag_heat_skip_when_below_threshold( - self, mock_tk: MagicMock - ) -> None: - window = _make_window(mock_tk, _snapshot()) - window._temp_result = _temp_check(temp_celsius=22.0) - window.render(_snapshot()) - assert not any("Would trigger heat-skip today." in t for t in _texts(mock_tk)) - - def test_shows_timeout_message(self, mock_tk: MagicMock) -> None: - window = _make_window(mock_tk, _snapshot()) - window._temp_result = _temp_check(temp_celsius=None, timed_out=True) - window.render(_snapshot()) - assert any("timed out" in t for t in _texts(mock_tk)) - - def test_shows_failure_message_when_not_timed_out(self, mock_tk: MagicMock) -> None: - window = _make_window(mock_tk, _snapshot()) - window._temp_result = _temp_check(temp_celsius=None, timed_out=False) - window.render(_snapshot()) - assert any("failed (network/API error)" in t for t in _texts(mock_tk)) - - -class TestTemperatureCheckFlow: - def test_init_starts_temperature_future(self, mock_tk: MagicMock) -> None: - window = _make_window(mock_tk, _snapshot()) - assert window._temp_future is not None - - def test_poll_routes_to_result_when_future_done(self, mock_tk: MagicMock) -> None: - window = _make_window(mock_tk, _snapshot()) - mock_future = MagicMock() - mock_future.done.return_value = True - mock_future.result.return_value = _temp_check(temp_celsius=25.0) - window._temp_future = mock_future - with patch.object(window, "_on_temperature_check_result") as mock_handle: - window._poll_temperature_check() - mock_handle.assert_called_once_with(mock_future.result.return_value) - - def test_poll_waits_when_future_not_done(self, mock_tk: MagicMock) -> None: - window = _make_window(mock_tk, _snapshot()) - mock_future = MagicMock() - mock_future.done.return_value = False - window._temp_future = mock_future - with patch.object(window, "_on_temperature_check_result") as mock_handle: - window._poll_temperature_check() - mock_handle.assert_not_called() - window.root.after.assert_called_with(500, window._poll_temperature_check) - - def test_poll_waits_when_future_is_none(self, mock_tk: MagicMock) -> None: - window = _make_window(mock_tk, _snapshot()) - window._temp_future = None - with patch.object(window, "_on_temperature_check_result") as mock_handle: - window._poll_temperature_check() - mock_handle.assert_not_called() - - def test_result_rerenders_last_snapshot_without_gathering_from_disk( - self, mock_tk: MagicMock - ) -> None: - """The temperature reading is independent of on-disk workout state — - must not trigger a real ``gather_status()`` disk read.""" - window = _make_window(mock_tk, _snapshot()) - result = _temp_check(temp_celsius=19.0) - with patch("screen_locker.status_view.gather_status") as mock_gather: - window._on_temperature_check_result(result) - assert window._temp_result == result - mock_gather.assert_not_called() - - def test_refresh_clears_result_and_restarts_check(self, mock_tk: MagicMock) -> None: - window = _make_window(mock_tk, _snapshot(), on_refresh=MagicMock()) - window._temp_result = _temp_check(temp_celsius=30.0) - with patch.object(window, "_start_temperature_check") as mock_start: - window._on_refresh_clicked() - assert window._temp_result is None - mock_start.assert_called_once() diff --git a/screen_locker/tests/test_status_view_part6.py b/screen_locker/tests/test_status_view_part6.py new file mode 100644 index 0000000..789efcc --- /dev/null +++ b/screen_locker/tests/test_status_view_part6.py @@ -0,0 +1,193 @@ +"""Tests for status_view.py: phone-check flow, buttons, main() CLI entry point.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker.status_view import ( + StatusWindow, + main, +) +from screen_locker.tests._status_view_helpers import ( + _lock_explanation, + _make_window, + _snapshot, + _temp_check, + _texts, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + import pytest + + +class TestMain: + """Main.""" + + def test_summary_flag_prints_and_returns( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Summary flag prints and returns.""" + with patch("screen_locker.status_view.gather_status", return_value=_snapshot()): + main(["--summary"]) + out = capsys.readouterr().out + assert "workouts" in out + + def test_state_flag_prints_and_returns( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """State flag prints and returns.""" + with patch( + "screen_locker.status_view.gather_status", + return_value=_snapshot(lock_explanation=_lock_explanation(fired=True)), + ): + main(["--state"]) + out = capsys.readouterr().out + assert out.strip() == "lock" + + def test_no_flags_opens_window(self, mock_tk: MagicMock) -> None: + """No flags opens window.""" + with patch("screen_locker.status_view.gather_status", return_value=_snapshot()): + main([]) + mock_tk.Tk.assert_called_once() + mock_tk.Tk.return_value.mainloop.assert_called_once() + + def test_no_flags_refresh_closure_regathers_and_rerenders( + self, mock_tk: MagicMock + ) -> None: + """Exercises main()'s inner refresh() closure passed as on_refresh.""" + captured: dict[str, Callable[[], None]] = {} + + class _CapturingWindow(StatusWindow): + def __init__( + self, + root: object, + snapshot: object, + *, + on_refresh: Callable[[], None], + **kwargs: object, + ) -> None: + captured["on_refresh"] = on_refresh + super().__init__(root, snapshot, on_refresh=on_refresh, **kwargs) + + with ( + patch( + "screen_locker.status_view.gather_status", return_value=_snapshot() + ) as mock_gather, + patch("screen_locker.status_view.StatusWindow", _CapturingWindow), + ): + main([]) + calls_before = mock_gather.call_count + captured["on_refresh"]() + + assert mock_gather.call_count == calls_before + 1 + + +class TestTemperatureSectionRendering: + """TemperatureSectionRendering.""" + + def test_shows_checking_message_before_result(self, mock_tk: MagicMock) -> None: + """Shows checking message before result.""" + window = _make_window(mock_tk, _snapshot()) + window._temp_result = None + window.render(_snapshot()) + assert any("Checking Warsaw temperature" in t for t in _texts(mock_tk)) + + def test_shows_resolved_temperature(self, mock_tk: MagicMock) -> None: + """Shows resolved temperature.""" + window = _make_window(mock_tk, _snapshot()) + window._temp_result = _temp_check(temp_celsius=22.0) + window.render(_snapshot()) + assert any("Warsaw: 22" in t for t in _texts(mock_tk)) + + def test_flags_would_trigger_heat_skip_when_hot(self, mock_tk: MagicMock) -> None: + """Flags would trigger heat skip when hot.""" + window = _make_window(mock_tk, _snapshot()) + window._temp_result = _temp_check(temp_celsius=35.0) + window.render(_snapshot()) + assert any("Would trigger heat-skip today." in t for t in _texts(mock_tk)) + + def test_does_not_flag_heat_skip_when_below_threshold( + self, mock_tk: MagicMock + ) -> None: + """Does not flag heat skip when below threshold.""" + window = _make_window(mock_tk, _snapshot()) + window._temp_result = _temp_check(temp_celsius=22.0) + window.render(_snapshot()) + assert not any("Would trigger heat-skip today." in t for t in _texts(mock_tk)) + + def test_shows_timeout_message(self, mock_tk: MagicMock) -> None: + """Shows timeout message.""" + window = _make_window(mock_tk, _snapshot()) + window._temp_result = _temp_check(temp_celsius=None, timed_out=True) + window.render(_snapshot()) + assert any("timed out" in t for t in _texts(mock_tk)) + + def test_shows_failure_message_when_not_timed_out(self, mock_tk: MagicMock) -> None: + """Shows failure message when not timed out.""" + window = _make_window(mock_tk, _snapshot()) + window._temp_result = _temp_check(temp_celsius=None, timed_out=False) + window.render(_snapshot()) + assert any("failed (network/API error)" in t for t in _texts(mock_tk)) + + +class TestTemperatureCheckFlow: + """TemperatureCheckFlow.""" + + def test_init_starts_temperature_future(self, mock_tk: MagicMock) -> None: + """Init starts temperature future.""" + window = _make_window(mock_tk, _snapshot()) + assert window._temp_future is not None + + def test_poll_routes_to_result_when_future_done(self, mock_tk: MagicMock) -> None: + """Poll routes to result when future done.""" + window = _make_window(mock_tk, _snapshot()) + mock_future = MagicMock() + mock_future.done.return_value = True + mock_future.result.return_value = _temp_check(temp_celsius=25.0) + window._temp_future = mock_future + with patch.object(window, "_on_temperature_check_result") as mock_handle: + window._poll_temperature_check() + mock_handle.assert_called_once_with(mock_future.result.return_value) + + def test_poll_waits_when_future_not_done(self, mock_tk: MagicMock) -> None: + """Poll waits when future not done.""" + window = _make_window(mock_tk, _snapshot()) + mock_future = MagicMock() + mock_future.done.return_value = False + window._temp_future = mock_future + with patch.object(window, "_on_temperature_check_result") as mock_handle: + window._poll_temperature_check() + mock_handle.assert_not_called() + window.root.after.assert_called_with(500, window._poll_temperature_check) + + def test_poll_waits_when_future_is_none(self, mock_tk: MagicMock) -> None: + """Poll waits when future is none.""" + window = _make_window(mock_tk, _snapshot()) + window._temp_future = None + with patch.object(window, "_on_temperature_check_result") as mock_handle: + window._poll_temperature_check() + mock_handle.assert_not_called() + + def test_result_rerenders_last_snapshot_without_gathering_from_disk( + self, mock_tk: MagicMock + ) -> None: + """The temperature reading is independent of on-disk workout state — + must not trigger a real ``gather_status()`` disk read.""" + window = _make_window(mock_tk, _snapshot()) + result = _temp_check(temp_celsius=19.0) + with patch("screen_locker.status_view.gather_status") as mock_gather: + window._on_temperature_check_result(result) + assert window._temp_result == result + mock_gather.assert_not_called() + + def test_refresh_clears_result_and_restarts_check(self, mock_tk: MagicMock) -> None: + """Refresh clears result and restarts check.""" + window = _make_window(mock_tk, _snapshot(), on_refresh=MagicMock()) + window._temp_result = _temp_check(temp_celsius=30.0) + with patch.object(window, "_start_temperature_check") as mock_start: + window._on_refresh_clicked() + assert window._temp_result is None + mock_start.assert_called_once() diff --git a/screen_locker/tests/test_weekly_logic.py b/screen_locker/tests/test_weekly_logic.py index e98f426..09f51e4 100644 --- a/screen_locker/tests/test_weekly_logic.py +++ b/screen_locker/tests/test_weekly_logic.py @@ -17,12 +17,15 @@ from screen_locker.tests.conftest import ( class TestRelaxedDayBranch: + """RelaxedDayBranch.""" + def test_relaxed_day_sets_flag_instead_of_exiting( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: + """Relaxed day sets flag instead of exiting.""" locker = create_locker_relaxed_day(mock_tk, tmp_path) assert locker._relaxed_day_mode is True mock_sys_exit.assert_not_called() @@ -33,6 +36,7 @@ class TestRelaxedDayBranch: mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: + """Relaxed day calls start relaxed flow.""" with ( patch.object(Path, "resolve", return_value=tmp_path), patch.object(ScreenLocker, "has_logged_today", return_value=False), @@ -65,6 +69,7 @@ class TestRelaxedDayBranch: mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: + """Relaxed day uses small window not fullscreen.""" with ( patch.object(Path, "resolve", return_value=tmp_path), patch.object(ScreenLocker, "has_logged_today", return_value=False), @@ -99,6 +104,7 @@ class TestRelaxedDayBranch: mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: + """Relaxed day no grab input.""" with ( patch.object(Path, "resolve", return_value=tmp_path), patch.object(ScreenLocker, "has_logged_today", return_value=False), @@ -131,6 +137,7 @@ class TestRelaxedDayBranch: mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: + """Has logged today exits before relaxed check.""" create_locker_relaxed_day(mock_tk, tmp_path, has_logged=True) mock_sys_exit.assert_called_once_with(0) @@ -141,12 +148,15 @@ class TestRelaxedDayBranch: class TestWeeklyMinimumBranch: + """WeeklyMinimumBranch.""" + def test_weekly_minimum_met_exits( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: + """Weekly minimum met exits.""" with patch( "screen_locker._startup_checks.has_weekly_minimum", return_value=True, @@ -163,6 +173,7 @@ class TestWeeklyMinimumBranch: ) -> None: # create_locker already stubs _start_phone_check; just verify no exit # and _relaxed_day_mode stays False (full lock path taken). + """Weekly minimum not met shows full lock.""" with patch( "screen_locker._startup_checks.has_weekly_minimum", return_value=False, @@ -178,6 +189,7 @@ class TestWeeklyMinimumBranch: mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: + """Weekly minimum not checked on relaxed day.""" with patch( "screen_locker._startup_checks.has_weekly_minimum", ) as mock_weekly: @@ -191,6 +203,7 @@ class TestWeeklyMinimumBranch: mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: + """Has logged exits before weekly check.""" with patch( "screen_locker._startup_checks.has_weekly_minimum", ) as mock_weekly: @@ -202,189 +215,3 @@ class TestWeeklyMinimumBranch: # --------------------------------------------------------------------------- # Relaxed-day UI flow methods # --------------------------------------------------------------------------- - - -class TestStartRelaxedDayFlow: - def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker: - return create_locker(mock_tk, tmp_path) - - def test_shows_weekly_count_in_text( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch( - "screen_locker._ui_flows_relaxed.count_weekly_workouts", - return_value=2, - ), - patch.object(locker, "_text") as mock_text, - patch.object(locker, "_label"), - patch.object(locker, "_button_row"), - patch.object(locker, "_button"), - patch.object(locker, "clear_container"), - ): - locker._start_relaxed_day_flow() - - all_text = " ".join(str(c) for c in mock_text.call_args_list) - assert "2" in all_text - assert "5" in all_text - - def test_skip_button_wires_close( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch( - "screen_locker._ui_flows_relaxed.count_weekly_workouts", - return_value=0, - ), - patch.object(locker, "_button") as mock_button, - patch.object(locker, "_label"), - patch.object(locker, "_text"), - patch.object(locker, "_button_row", return_value=MagicMock()), - patch.object(locker, "clear_container"), - ): - locker._start_relaxed_day_flow() - - skip_cmds = [ - c.kwargs["command"] - for c in mock_button.call_args_list - if "Skip" in str(c.args) - ] - assert any(cmd == locker.close for cmd in skip_cmds) - - def test_log_button_wires_relaxed_phone_check( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch( - "screen_locker._ui_flows_relaxed.count_weekly_workouts", - return_value=1, - ), - patch.object(locker, "_button") as mock_button, - patch.object(locker, "_label"), - patch.object(locker, "_text"), - patch.object(locker, "_button_row", return_value=MagicMock()), - patch.object(locker, "clear_container"), - ): - locker._start_relaxed_day_flow() - - log_cmds = [ - c.kwargs["command"] - for c in mock_button.call_args_list - if "Log" in str(c.args) - ] - assert any(cmd == locker._start_relaxed_phone_check for cmd in log_cmds) - - -class TestStartRelaxedPhoneCheck: - def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker: - return create_locker(mock_tk, tmp_path) - - def test_submits_phone_verify_and_polls( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with patch.object( - locker, "_verify_phone_workout", return_value=("verified", "ok") - ): - locker._start_relaxed_phone_check() - - assert locker._phone_future is not None - locker.root.after.assert_called() - - def test_poll_routes_when_done( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - mock_future = MagicMock() - mock_future.done.return_value = True - mock_future.result.return_value = ("verified", "ok") - locker._phone_future = mock_future - with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle: - locker._poll_relaxed_phone_check() - mock_handle.assert_called_once_with("verified", "ok") - - def test_poll_waits_when_not_done( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - mock_future = MagicMock() - mock_future.done.return_value = False - locker._phone_future = mock_future - with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle: - locker._poll_relaxed_phone_check() - mock_handle.assert_not_called() - locker.root.after.assert_called_with(500, locker._poll_relaxed_phone_check) - - def test_poll_with_none_future_waits( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - locker._phone_future = None - with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle: - locker._poll_relaxed_phone_check() - mock_handle.assert_not_called() - - -class TestHandleRelaxedPhoneResult: - """Tests for _handle_relaxed_phone_result routing and the retry screen.""" - - def test_verified_saves_and_schedules_unlock( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = create_locker(mock_tk, tmp_path) - locker._handle_relaxed_phone_result("verified", "Workout verified!") - assert locker.workout_data["type"] == "phone_verified" - assert locker.workout_data["source"] == "Workout verified!" - locker.root.after.assert_called_with(1500, locker.unlock_screen) - - def test_non_verified_shows_retry( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = create_locker(mock_tk, tmp_path) - with patch.object(locker, "_show_relaxed_retry") as mock_retry: - locker._handle_relaxed_phone_result("not_verified", "nope") - mock_retry.assert_called_once_with("nope", "not_verified") - - def test_show_relaxed_retry_renders_buttons( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = create_locker(mock_tk, tmp_path) - locker._show_relaxed_retry("No workout", "not_verified") - button_texts = { - call.kwargs.get("text") for call in mock_tk.Button.call_args_list - } - assert "TRY AGAIN" in button_texts - assert "Close (Skip)" in button_texts diff --git a/screen_locker/tests/test_weekly_logic_part4.py b/screen_locker/tests/test_weekly_logic_part4.py new file mode 100644 index 0000000..d341a0c --- /dev/null +++ b/screen_locker/tests/test_weekly_logic_part4.py @@ -0,0 +1,215 @@ +"""Tests for weekly workout enforcement and relaxed-day (Tue-Thu) logic.""" + +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 + + from screen_locker.screen_lock import ScreenLocker + + +class TestStartRelaxedDayFlow: + """StartRelaxedDayFlow.""" + + def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker: + return create_locker(mock_tk, tmp_path) + + def test_shows_weekly_count_in_text( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Shows weekly count in text.""" + locker = self._make_locker(mock_tk, tmp_path) + with ( + patch( + "screen_locker._ui_flows_relaxed.count_weekly_workouts", + return_value=2, + ), + patch.object(locker, "_text") as mock_text, + patch.object(locker, "_label"), + patch.object(locker, "_button_row"), + patch.object(locker, "_button"), + patch.object(locker, "clear_container"), + ): + locker._start_relaxed_day_flow() + + all_text = " ".join(str(c) for c in mock_text.call_args_list) + assert "2" in all_text + assert "5" in all_text + + def test_skip_button_wires_close( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Skip button wires close.""" + locker = self._make_locker(mock_tk, tmp_path) + with ( + patch( + "screen_locker._ui_flows_relaxed.count_weekly_workouts", + return_value=0, + ), + patch.object(locker, "_button") as mock_button, + patch.object(locker, "_label"), + patch.object(locker, "_text"), + patch.object(locker, "_button_row", return_value=MagicMock()), + patch.object(locker, "clear_container"), + ): + locker._start_relaxed_day_flow() + + skip_cmds = [ + c.kwargs["command"] + for c in mock_button.call_args_list + if "Skip" in str(c.args) + ] + assert any(cmd == locker.close for cmd in skip_cmds) + + def test_log_button_wires_relaxed_phone_check( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Log button wires relaxed phone check.""" + locker = self._make_locker(mock_tk, tmp_path) + with ( + patch( + "screen_locker._ui_flows_relaxed.count_weekly_workouts", + return_value=1, + ), + patch.object(locker, "_button") as mock_button, + patch.object(locker, "_label"), + patch.object(locker, "_text"), + patch.object(locker, "_button_row", return_value=MagicMock()), + patch.object(locker, "clear_container"), + ): + locker._start_relaxed_day_flow() + + log_cmds = [ + c.kwargs["command"] + for c in mock_button.call_args_list + if "Log" in str(c.args) + ] + assert any(cmd == locker._start_relaxed_phone_check for cmd in log_cmds) + + +class TestStartRelaxedPhoneCheck: + """StartRelaxedPhoneCheck.""" + + def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker: + return create_locker(mock_tk, tmp_path) + + def test_submits_phone_verify_and_polls( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Submits phone verify and polls.""" + locker = self._make_locker(mock_tk, tmp_path) + with patch.object( + locker, "_verify_phone_workout", return_value=("verified", "ok") + ): + locker._start_relaxed_phone_check() + + assert locker._phone_future is not None + locker.root.after.assert_called() + + def test_poll_routes_when_done( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Poll routes when done.""" + locker = self._make_locker(mock_tk, tmp_path) + mock_future = MagicMock() + mock_future.done.return_value = True + mock_future.result.return_value = ("verified", "ok") + locker._phone_future = mock_future + with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle: + locker._poll_relaxed_phone_check() + mock_handle.assert_called_once_with("verified", "ok") + + def test_poll_waits_when_not_done( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Poll waits when not done.""" + locker = self._make_locker(mock_tk, tmp_path) + mock_future = MagicMock() + mock_future.done.return_value = False + locker._phone_future = mock_future + with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle: + locker._poll_relaxed_phone_check() + mock_handle.assert_not_called() + locker.root.after.assert_called_with(500, locker._poll_relaxed_phone_check) + + def test_poll_with_none_future_waits( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Poll with none future waits.""" + locker = self._make_locker(mock_tk, tmp_path) + locker._phone_future = None + with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle: + locker._poll_relaxed_phone_check() + mock_handle.assert_not_called() + + +class TestHandleRelaxedPhoneResult: + """Tests for _handle_relaxed_phone_result routing and the retry screen.""" + + def test_verified_saves_and_schedules_unlock( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Verified saves and schedules unlock.""" + locker = create_locker(mock_tk, tmp_path) + locker._handle_relaxed_phone_result("verified", "Workout verified!") + assert locker.workout_data["type"] == "phone_verified" + assert locker.workout_data["source"] == "Workout verified!" + locker.root.after.assert_called_with(1500, locker.unlock_screen) + + def test_non_verified_shows_retry( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Non verified shows retry.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object(locker, "_show_relaxed_retry") as mock_retry: + locker._handle_relaxed_phone_result("not_verified", "nope") + mock_retry.assert_called_once_with("nope", "not_verified") + + def test_show_relaxed_retry_renders_buttons( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Show relaxed retry renders buttons.""" + locker = create_locker(mock_tk, tmp_path) + locker._show_relaxed_retry("No workout", "not_verified") + button_texts = { + call.kwargs.get("text") for call in mock_tk.Button.call_args_list + } + assert "TRY AGAIN" in button_texts + assert "Close (Skip)" in button_texts diff --git a/screen_locker/tests/test_workout_sync_part2.py b/screen_locker/tests/test_workout_sync_part2.py index c2a0b77..7c3272a 100644 --- a/screen_locker/tests/test_workout_sync_part2.py +++ b/screen_locker/tests/test_workout_sync_part2.py @@ -6,7 +6,6 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -14,18 +13,12 @@ from crdt_sync import ( ConfigError, FirebaseAuthError, FirebaseSyncError, - GitHubSyncError, ) import pytest from screen_locker import _workout_sync from screen_locker.tests._workout_sync_fixtures import ( _firebase_config, - _manual_payload, - _manual_record_dict, - _multi_device_client, - _session_payload, - _session_record_dict, ) if TYPE_CHECKING: @@ -36,16 +29,19 @@ class TestSyncClient: """Every configuration of (GitHub token, Firebase config), plus failure.""" def test_returns_none_when_nothing_is_configured(self) -> None: + """Returns none when nothing is configured.""" assert _workout_sync.sync_client() is None def test_warns_when_nothing_is_configured( self, caplog: pytest.LogCaptureFixture ) -> None: + """Warns when nothing is configured.""" with caplog.at_level("WARNING"): _workout_sync.sync_client() assert "sync is OFF" in caplog.text def test_uses_github_alone_when_only_the_token_exists(self) -> None: + """Uses github alone when only the token exists.""" _workout_sync.SYNC_TOKEN_FILE.write_text("tok") github = MagicMock() with patch.object(_workout_sync, "GitHubSyncClient", return_value=github): @@ -54,6 +50,7 @@ class TestSyncClient: def test_mirrors_when_both_the_token_and_firebase_exist( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """Mirrors when both the token and firebase exist.""" _workout_sync.SYNC_TOKEN_FILE.write_text("tok") _firebase_config(_workout_sync, tmp_path, monkeypatch) github = MagicMock() @@ -98,6 +95,7 @@ class TestSyncClient: monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: + """Warns when firebase only is unusable.""" _firebase_config(_workout_sync, tmp_path, monkeypatch) def _boom(*_args: object, **_kwargs: object) -> None: @@ -108,293 +106,3 @@ class TestSyncClient: with caplog.at_level("WARNING"): _workout_sync.sync_client() assert "backend down" in caplog.text - - -class TestPullSyncedWorkout: - def test_returns_none_none_when_no_token_is_configured(self) -> None: - with patch.object(_workout_sync, "GitHubSyncClient") as client_cls: - assert _workout_sync.pull_synced_workout() == (None, None) - client_cls.assert_not_called() - - def test_returns_the_error_message_on_a_sync_error(self) -> None: - """A failed device LISTING is a real error, not "nothing to sync".""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - client = MagicMock() - client.list_directory.side_effect = GitHubSyncError("offline") - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - assert _workout_sync.pull_synced_workout() == (None, "offline") - - def test_does_not_propagate_a_firebase_sync_error(self) -> None: - """Regression: FirebaseSyncError is a SIBLING of GitHubSyncError. - - Catching only ``GitHubSyncError`` let a Firebase failure escape and - crash the locker with status=1/FAILURE. It must come back as an error - tuple like any other backend failure. - """ - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - client = MagicMock() - client.list_directory.side_effect = FirebaseSyncError("firebase exploded") - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - data, error = _workout_sync.pull_synced_workout() - assert data is None - assert error == "firebase exploded" - - def test_does_not_propagate_a_firebase_error_on_a_firebase_only_machine( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """The crash's real shape: no PAT, Firebase primary, backend throws. - - The configuration the locker actually died in, pinned in its own right - so a future GitHub-only narrowing of the handler cannot pass. - """ - _firebase_config(_workout_sync, tmp_path, monkeypatch) - client = MagicMock() - client.list_directory.side_effect = FirebaseSyncError("firebase exploded") - monkeypatch.setattr(_workout_sync, "firebase_client_for", lambda _app: client) - data, error = _workout_sync.pull_synced_workout() - assert data is None - assert error == "firebase exploded" - - def test_returns_none_none_when_nothing_has_been_pushed_yet(self) -> None: - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - client = _multi_device_client({"phone": None}) - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - assert _workout_sync.pull_synced_workout() == (None, None) - - def test_returns_none_none_when_there_are_no_devices_at_all(self) -> None: - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - client = _multi_device_client({}) - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - assert _workout_sync.pull_synced_workout() == (None, None) - - def test_returns_the_payload_on_success(self) -> None: - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - payload = _session_payload() - client = _multi_device_client( - {"phone": json.dumps({"a": _session_record_dict("session:a", payload)})} - ) - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - assert _workout_sync.pull_synced_workout() == (payload, None) - - def test_finds_a_session_in_a_uuid_named_device_directory(self) -> None: - """The actual bug: the phone's dir is a per-install uuid, not "phone". - - Reading a single hardcoded ``devices/phone/log.json`` returned a stale - session forever while newer ones sat unread one directory over. - """ - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - fresh = _session_payload(note="FROM-UUID-DIR") - client = _multi_device_client( - { - "pc": json.dumps( - { - "old": _session_record_dict( - "session:old", - _session_payload(note="STALE"), - wall_time_ms=100, - ) - } - ), - "0f1d2c3b-4a59-4c6d-8e7f-a0b1c2d3e4f5": json.dumps( - { - "new": _session_record_dict( - "session:new", fresh, wall_time_ms=200 - ) - } - ), - } - ) - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - assert _workout_sync.pull_synced_workout() == (fresh, None) - - def test_returns_the_highest_clock_session_across_devices(self) -> None: - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - newest = _session_payload(note="NEWEST") - client = _multi_device_client( - { - "phone-a": json.dumps( - { - "a": _session_record_dict( - "session:a", - _session_payload(note="OLDER"), - wall_time_ms=100, - ) - } - ), - "phone-b": json.dumps( - {"b": _session_record_dict("session:b", newest, wall_time_ms=300)} - ), - "phone-c": json.dumps( - { - "c": _session_record_dict( - "session:c", - _session_payload(note="MIDDLE"), - wall_time_ms=200, - ) - } - ), - } - ) - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - data, error = _workout_sync.pull_synced_workout() - assert error is None - assert data == newest - - def test_ignores_non_session_records_in_the_same_logs(self) -> None: - """``phone_verified`` / ``runnerup_verified`` share these device logs. - - They are non-manual but have no ``exercises``, so "not manual" alone - would hand back a record with none of the fields a caller expects -- - even though here they carry the HIGHEST clocks in the log. - """ - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - session = _session_payload(note="REAL-SESSION") - client = _multi_device_client( - { - "phone": json.dumps( - { - "s": _session_record_dict( - "session:a", session, wall_time_ms=100 - ), - "v": _session_record_dict( - "verified:1", - {"kind": "phone_verified", "date": "2026-08-10"}, - wall_time_ms=900, - ), - "r": _session_record_dict( - "verified:2", - {"kind": "runnerup_verified", "date": "2026-08-10"}, - wall_time_ms=950, - ), - "m": _manual_record_dict( - "manual:1", _manual_payload(), wall_time_ms=999 - ), - } - ) - } - ) - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - data, error = _workout_sync.pull_synced_workout() - assert error is None - assert data == session - - def test_skips_a_corrupt_device_log_instead_of_failing(self) -> None: - """A corrupt log costs that device's sessions, not the whole pull.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - payload = _session_payload() - client = _multi_device_client( - { - "corrupt": "{not valid json", - "phone": json.dumps({"a": _session_record_dict("session:a", payload)}), - } - ) - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - assert _workout_sync.pull_synced_workout() == (payload, None) - - def test_returns_none_none_when_every_device_log_is_corrupt(self) -> None: - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - client = _multi_device_client({"corrupt": "{not valid json"}) - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - assert _workout_sync.pull_synced_workout() == (None, None) - - -class TestPullAllManualRecords: - def test_returns_empty_when_no_token(self) -> None: - assert _workout_sync.pull_all_manual_records() == [] - - def test_returns_empty_when_listing_fails(self) -> None: - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - client = MagicMock() - client.list_directory.side_effect = GitHubSyncError("offline") - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - assert _workout_sync.pull_all_manual_records() == [] - - def test_merges_manual_records_across_devices(self) -> None: - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - client = _multi_device_client( - { - "phone": json.dumps( - {"a": _manual_record_dict("manual:a", _manual_payload())} - ), - "pc": json.dumps( - {"b": _manual_record_dict("manual:b", _manual_payload())} - ), - } - ) - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - result = _workout_sync.pull_all_manual_records() - assert sorted(rid for rid, _ in result) == ["manual:a", "manual:b"] - - def test_skips_missing_and_corrupt_device_logs(self) -> None: - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - client = _multi_device_client( - { - "phone": json.dumps( - {"a": _manual_record_dict("manual:a", _manual_payload())} - ), - "gone": GitHubSyncError("404"), - "empty": None, - "corrupt": "{not json", - } - ) - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - result = _workout_sync.pull_all_manual_records() - assert [rid for rid, _ in result] == ["manual:a"] - - def test_dedups_same_id_keeping_highest_clock(self) -> None: - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - client = _multi_device_client( - { - "phone": json.dumps( - { - "a": _manual_record_dict( - "manual:a", - _manual_payload(cost="OLD"), - wall_time_ms=100, - ) - } - ), - "pc": json.dumps( - { - "a": _manual_record_dict( - "manual:a", - _manual_payload(cost="NEW"), - wall_time_ms=200, - ) - } - ), - } - ) - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - result = _workout_sync.pull_all_manual_records() - assert len(result) == 1 - assert result[0][1]["cost"] == "NEW" - - def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None: - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - client = _multi_device_client( - { - "phone": json.dumps( - { - "a": _manual_record_dict( - "manual:a", - _manual_payload(cost="NEW"), - wall_time_ms=200, - ) - } - ), - "pc": json.dumps( - { - "a": _manual_record_dict( - "manual:a", - _manual_payload(cost="OLD"), - wall_time_ms=100, - ) - } - ), - } - ) - with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): - result = _workout_sync.pull_all_manual_records() - assert len(result) == 1 - assert result[0][1]["cost"] == "NEW" diff --git a/screen_locker/tests/test_workout_sync_part5.py b/screen_locker/tests/test_workout_sync_part5.py new file mode 100644 index 0000000..f2ea67b --- /dev/null +++ b/screen_locker/tests/test_workout_sync_part5.py @@ -0,0 +1,227 @@ +"""Backend selection and the two public pulls, split from ``test_workout_sync``. + +``sync_client`` decides WHICH backend is read; ``pull_synced_workout`` and +``pull_all_manual_records`` are what the locker actually calls. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from crdt_sync import ( + FirebaseSyncError, + GitHubSyncError, +) + +from screen_locker import _workout_sync +from screen_locker.tests._workout_sync_fixtures import ( + _firebase_config, + _manual_payload, + _manual_record_dict, + _multi_device_client, + _session_payload, + _session_record_dict, +) + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +class TestPullSyncedWorkout: + """PullSyncedWorkout.""" + + def test_returns_none_none_when_no_token_is_configured(self) -> None: + """Returns none none when no token is configured.""" + with patch.object(_workout_sync, "GitHubSyncClient") as client_cls: + assert _workout_sync.pull_synced_workout() == (None, None) + client_cls.assert_not_called() + + def test_returns_the_error_message_on_a_sync_error(self) -> None: + """A failed device LISTING is a real error, not "nothing to sync".""" + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = MagicMock() + client.list_directory.side_effect = GitHubSyncError("offline") + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + assert _workout_sync.pull_synced_workout() == (None, "offline") + + def test_does_not_propagate_a_firebase_sync_error(self) -> None: + """Regression: FirebaseSyncError is a SIBLING of GitHubSyncError. + + Catching only ``GitHubSyncError`` let a Firebase failure escape and + crash the locker with status=1/FAILURE. It must come back as an error + tuple like any other backend failure. + """ + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = MagicMock() + client.list_directory.side_effect = FirebaseSyncError("firebase exploded") + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + data, error = _workout_sync.pull_synced_workout() + assert data is None + assert error == "firebase exploded" + + def test_does_not_propagate_a_firebase_error_on_a_firebase_only_machine( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The crash's real shape: no PAT, Firebase primary, backend throws. + + The configuration the locker actually died in, pinned in its own right + so a future GitHub-only narrowing of the handler cannot pass. + """ + _firebase_config(_workout_sync, tmp_path, monkeypatch) + client = MagicMock() + client.list_directory.side_effect = FirebaseSyncError("firebase exploded") + monkeypatch.setattr(_workout_sync, "firebase_client_for", lambda _app: client) + data, error = _workout_sync.pull_synced_workout() + assert data is None + assert error == "firebase exploded" + + def test_returns_none_none_when_nothing_has_been_pushed_yet(self) -> None: + """Returns none none when nothing has been pushed yet.""" + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = _multi_device_client({"phone": None}) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + assert _workout_sync.pull_synced_workout() == (None, None) + + def test_returns_none_none_when_there_are_no_devices_at_all(self) -> None: + """Returns none none when there are no devices at all.""" + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = _multi_device_client({}) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + assert _workout_sync.pull_synced_workout() == (None, None) + + def test_returns_the_payload_on_success(self) -> None: + """Returns the payload on success.""" + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + payload = _session_payload() + client = _multi_device_client( + {"phone": json.dumps({"a": _session_record_dict("session:a", payload)})} + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + assert _workout_sync.pull_synced_workout() == (payload, None) + + def test_finds_a_session_in_a_uuid_named_device_directory(self) -> None: + """The actual bug: the phone's dir is a per-install uuid, not "phone". + + Reading a single hardcoded ``devices/phone/log.json`` returned a stale + session forever while newer ones sat unread one directory over. + """ + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + fresh = _session_payload(note="FROM-UUID-DIR") + client = _multi_device_client( + { + "pc": json.dumps( + { + "old": _session_record_dict( + "session:old", + _session_payload(note="STALE"), + wall_time_ms=100, + ) + } + ), + "0f1d2c3b-4a59-4c6d-8e7f-a0b1c2d3e4f5": json.dumps( + { + "new": _session_record_dict( + "session:new", fresh, wall_time_ms=200 + ) + } + ), + } + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + assert _workout_sync.pull_synced_workout() == (fresh, None) + + def test_returns_the_highest_clock_session_across_devices(self) -> None: + """Returns the highest clock session across devices.""" + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + newest = _session_payload(note="NEWEST") + client = _multi_device_client( + { + "phone-a": json.dumps( + { + "a": _session_record_dict( + "session:a", + _session_payload(note="OLDER"), + wall_time_ms=100, + ) + } + ), + "phone-b": json.dumps( + {"b": _session_record_dict("session:b", newest, wall_time_ms=300)} + ), + "phone-c": json.dumps( + { + "c": _session_record_dict( + "session:c", + _session_payload(note="MIDDLE"), + wall_time_ms=200, + ) + } + ), + } + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + data, error = _workout_sync.pull_synced_workout() + assert error is None + assert data == newest + + def test_ignores_non_session_records_in_the_same_logs(self) -> None: + """``phone_verified`` / ``runnerup_verified`` share these device logs. + + They are non-manual but have no ``exercises``, so "not manual" alone + would hand back a record with none of the fields a caller expects -- + even though here they carry the HIGHEST clocks in the log. + """ + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + session = _session_payload(note="REAL-SESSION") + client = _multi_device_client( + { + "phone": json.dumps( + { + "s": _session_record_dict( + "session:a", session, wall_time_ms=100 + ), + "v": _session_record_dict( + "verified:1", + {"kind": "phone_verified", "date": "2026-08-10"}, + wall_time_ms=900, + ), + "r": _session_record_dict( + "verified:2", + {"kind": "runnerup_verified", "date": "2026-08-10"}, + wall_time_ms=950, + ), + "m": _manual_record_dict( + "manual:1", _manual_payload(), wall_time_ms=999 + ), + } + ) + } + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + data, error = _workout_sync.pull_synced_workout() + assert error is None + assert data == session + + def test_skips_a_corrupt_device_log_instead_of_failing(self) -> None: + """A corrupt log costs that device's sessions, not the whole pull.""" + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + payload = _session_payload() + client = _multi_device_client( + { + "corrupt": "{not valid json", + "phone": json.dumps({"a": _session_record_dict("session:a", payload)}), + } + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + assert _workout_sync.pull_synced_workout() == (payload, None) + + def test_returns_none_none_when_every_device_log_is_corrupt(self) -> None: + """Returns none none when every device log is corrupt.""" + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = _multi_device_client({"corrupt": "{not valid json"}) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + assert _workout_sync.pull_synced_workout() == (None, None) diff --git a/screen_locker/tests/test_workout_sync_part6.py b/screen_locker/tests/test_workout_sync_part6.py new file mode 100644 index 0000000..0cf0050 --- /dev/null +++ b/screen_locker/tests/test_workout_sync_part6.py @@ -0,0 +1,131 @@ +"""Backend selection and the two public pulls, split from ``test_workout_sync``. + +``sync_client`` decides WHICH backend is read; ``pull_synced_workout`` and +``pull_all_manual_records`` are what the locker actually calls. +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +from crdt_sync import ( + GitHubSyncError, +) + +from screen_locker import _workout_sync +from screen_locker.tests._workout_sync_fixtures import ( + _manual_payload, + _manual_record_dict, + _multi_device_client, +) + + +class TestPullAllManualRecords: + """PullAllManualRecords.""" + + def test_returns_empty_when_no_token(self) -> None: + """Returns empty when no token.""" + assert _workout_sync.pull_all_manual_records() == [] + + def test_returns_empty_when_listing_fails(self) -> None: + """Returns empty when listing fails.""" + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = MagicMock() + client.list_directory.side_effect = GitHubSyncError("offline") + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + assert _workout_sync.pull_all_manual_records() == [] + + def test_merges_manual_records_across_devices(self) -> None: + """Merges manual records across devices.""" + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = _multi_device_client( + { + "phone": json.dumps( + {"a": _manual_record_dict("manual:a", _manual_payload())} + ), + "pc": json.dumps( + {"b": _manual_record_dict("manual:b", _manual_payload())} + ), + } + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + result = _workout_sync.pull_all_manual_records() + assert sorted(rid for rid, _ in result) == ["manual:a", "manual:b"] + + def test_skips_missing_and_corrupt_device_logs(self) -> None: + """Skips missing and corrupt device logs.""" + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = _multi_device_client( + { + "phone": json.dumps( + {"a": _manual_record_dict("manual:a", _manual_payload())} + ), + "gone": GitHubSyncError("404"), + "empty": None, + "corrupt": "{not json", + } + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + result = _workout_sync.pull_all_manual_records() + assert [rid for rid, _ in result] == ["manual:a"] + + def test_dedups_same_id_keeping_highest_clock(self) -> None: + """Dedups same ID keeping highest clock.""" + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = _multi_device_client( + { + "phone": json.dumps( + { + "a": _manual_record_dict( + "manual:a", + _manual_payload(cost="OLD"), + wall_time_ms=100, + ) + } + ), + "pc": json.dumps( + { + "a": _manual_record_dict( + "manual:a", + _manual_payload(cost="NEW"), + wall_time_ms=200, + ) + } + ), + } + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + result = _workout_sync.pull_all_manual_records() + assert len(result) == 1 + assert result[0][1]["cost"] == "NEW" + + def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None: + """Ignores a lower clock duplicate seen later.""" + _workout_sync.SYNC_TOKEN_FILE.write_text("tok") + client = _multi_device_client( + { + "phone": json.dumps( + { + "a": _manual_record_dict( + "manual:a", + _manual_payload(cost="NEW"), + wall_time_ms=200, + ) + } + ), + "pc": json.dumps( + { + "a": _manual_record_dict( + "manual:a", + _manual_payload(cost="OLD"), + wall_time_ms=100, + ) + } + ), + } + ) + with patch.object(_workout_sync, "GitHubSyncClient", return_value=client): + result = _workout_sync.pull_all_manual_records() + assert len(result) == 1 + assert result[0][1]["cost"] == "NEW"