mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 12:07:44 +01:00
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:
parent
5c27769642
commit
dd8296fd3f
@ -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"
|
||||
|
||||
238
screen_locker/tests/test_manual_workout_part2.py
Normal file
238
screen_locker/tests/test_manual_workout_part2.py
Normal 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"
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
295
screen_locker/tests/test_runnerup_verification_part10.py
Normal file
295
screen_locker/tests/test_runnerup_verification_part10.py
Normal 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
157
screen_locker/tests/test_runnerup_verification_part7.py
Normal file
157
screen_locker/tests/test_runnerup_verification_part7.py
Normal 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
|
||||
213
screen_locker/tests/test_runnerup_verification_part8.py
Normal file
213
screen_locker/tests/test_runnerup_verification_part8.py
Normal 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
|
||||
# ---------------------------------------------------------------------------
|
||||
119
screen_locker/tests/test_runnerup_verification_part9.py
Normal file
119
screen_locker/tests/test_runnerup_verification_part9.py
Normal 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -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
|
||||
|
||||
200
screen_locker/tests/test_sick_tracker_part2.py
Normal file
200
screen_locker/tests/test_sick_tracker_part2.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
212
screen_locker/tests/test_status_data_part2.py
Normal file
212
screen_locker/tests/test_status_data_part2.py
Normal 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"
|
||||
151
screen_locker/tests/test_status_data_part3.py
Normal file
151
screen_locker/tests/test_status_data_part3.py
Normal 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
|
||||
96
screen_locker/tests/test_status_data_part4.py
Normal file
96
screen_locker/tests/test_status_data_part4.py
Normal 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)
|
||||
@ -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()
|
||||
|
||||
193
screen_locker/tests/test_status_view_part6.py
Normal file
193
screen_locker/tests/test_status_view_part6.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
215
screen_locker/tests/test_weekly_logic_part4.py
Normal file
215
screen_locker/tests/test_weekly_logic_part4.py
Normal 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
|
||||
@ -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"
|
||||
|
||||
227
screen_locker/tests/test_workout_sync_part5.py
Normal file
227
screen_locker/tests/test_workout_sync_part5.py
Normal 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)
|
||||
131
screen_locker/tests/test_workout_sync_part6.py
Normal file
131
screen_locker/tests/test_workout_sync_part6.py
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user