Split 12 more test modules under the 250-line cap

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 19:34:59 +02:00
parent 5c27769642
commit dd8296fd3f
21 changed files with 2522 additions and 1902 deletions

View File

@ -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"

View File

@ -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"

View File

@ -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, "<not-valid xml << ")
assert locker._parse_tcx(path) is None
def test_missing_activity_element_returns_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""TCX with no <Activity> element returns None."""
locker = create_locker(mock_tk, tmp_path)
tcx = """\
<?xml version="1.0"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
</TrainingCenterDatabase>
"""
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
# ---------------------------------------------------------------------------

View File

@ -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 = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
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
# ---------------------------------------------------------------------------

View File

@ -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
# ---------------------------------------------------------------------------

View File

@ -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 = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
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

View File

@ -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 = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
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, "<not-valid xml << ")
assert locker._parse_tcx(path) is None
def test_missing_activity_element_returns_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""TCX with no <Activity> element returns None."""
locker = create_locker(mock_tk, tmp_path)
tcx = """\
<?xml version="1.0"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
</TrainingCenterDatabase>
"""
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
# ---------------------------------------------------------------------------

View File

@ -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 = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
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
# ---------------------------------------------------------------------------

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -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"