Split 13 test modules under the cap; document their test methods

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 19:16:18 +02:00
parent ee8f8bcf3a
commit a0d78bc719
26 changed files with 1389 additions and 976 deletions

View File

@ -151,106 +151,3 @@ class TestAdbShell:
["shell", "su", "-c", "ls /data"],
)
assert success is True
class TestIsPhoneConnected:
"""Tests for _is_phone_connected method."""
def test_phone_connected(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test phone detected as connected."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(
True,
"List of devices attached\nABC123\tdevice\n\n",
),
),
)
assert locker._is_phone_connected() is True
def test_phone_not_connected(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test no phone connected."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(True, "List of devices attached\n\n"),
),
)
object.__setattr__(
locker,
"_try_wireless_reconnect",
MagicMock(
return_value=False,
),
)
assert locker._is_phone_connected() is False
def test_phone_offline(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test phone connected but offline."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(
True,
"List of devices attached\nABC123\toffline\n\n",
),
),
)
object.__setattr__(
locker,
"_try_wireless_reconnect",
MagicMock(
return_value=False,
),
)
assert locker._is_phone_connected() is False
def test_adb_command_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test ADB command failure."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(False, ""),
),
)
object.__setattr__(
locker,
"_try_wireless_reconnect",
MagicMock(
return_value=False,
),
)
assert locker._is_phone_connected() is False

View File

@ -0,0 +1,115 @@
"""Tests for ADB commands, phone connection, and database operations."""
# pylint: disable=protected-access,unused-argument
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestIsPhoneConnected:
"""Tests for _is_phone_connected method."""
def test_phone_connected(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test phone detected as connected."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(
True,
"List of devices attached\nABC123\tdevice\n\n",
),
),
)
assert locker._is_phone_connected() is True
def test_phone_not_connected(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test no phone connected."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(True, "List of devices attached\n\n"),
),
)
object.__setattr__(
locker,
"_try_wireless_reconnect",
MagicMock(
return_value=False,
),
)
assert locker._is_phone_connected() is False
def test_phone_offline(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test phone connected but offline."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(
True,
"List of devices attached\nABC123\toffline\n\n",
),
),
)
object.__setattr__(
locker,
"_try_wireless_reconnect",
MagicMock(
return_value=False,
),
)
assert locker._is_phone_connected() is False
def test_adb_command_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test ADB command failure."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(False, ""),
),
)
object.__setattr__(
locker,
"_try_wireless_reconnect",
MagicMock(
return_value=False,
),
)
assert locker._is_phone_connected() is False

View File

@ -9,8 +9,6 @@ from unittest.mock import patch
from screen_locker import _compliance_state
from screen_locker._compliance_state import (
AutoUpgradeOpportunity,
describe_auto_upgrade_opportunity,
has_logged_today,
is_early_bird_pending,
is_scheduled_skip_today,
@ -30,24 +28,29 @@ class TestIsScheduledSkipToday:
"""Mirrors test_scheduled_skip.py's coverage, for the standalone function."""
def test_missing_file(self, tmp_path: Path) -> None:
"""A missing skips file means today is not a scheduled skip."""
assert is_scheduled_skip_today(tmp_path / "skips.json") is False
def test_today_listed(self, tmp_path: Path) -> None:
"""Today's date in the skips list makes it a scheduled skip."""
skip_file = tmp_path / "skips.json"
skip_file.write_text(json.dumps([_today()]))
assert is_scheduled_skip_today(skip_file) is True
def test_today_not_listed(self, tmp_path: Path) -> None:
"""A list without today is not a scheduled skip."""
skip_file = tmp_path / "skips.json"
skip_file.write_text(json.dumps(["1999-01-01"]))
assert is_scheduled_skip_today(skip_file) is False
def test_corrupt_json(self, tmp_path: Path) -> None:
"""Unparsable JSON fails closed: not a skip."""
skip_file = tmp_path / "skips.json"
skip_file.write_text("{bad}")
assert is_scheduled_skip_today(skip_file) is False
def test_explicit_today_override(self, tmp_path: Path) -> None:
"""The `today` argument overrides the real date."""
skip_file = tmp_path / "skips.json"
skip_file.write_text(json.dumps(["2020-05-05"]))
assert is_scheduled_skip_today(skip_file, today="2020-05-05") is True
@ -57,19 +60,23 @@ class TestHasLoggedToday:
"""Mirrors test_init_and_log.py's HMAC branches, for the standalone function."""
def test_missing_file(self, tmp_path: Path) -> None:
"""A missing log means nothing was logged today."""
assert has_logged_today(tmp_path / "log.json") is False
def test_corrupt_json(self, tmp_path: Path) -> None:
"""Unparsable JSON fails closed: nothing logged."""
log_file = tmp_path / "log.json"
log_file.write_text("{bad}")
assert has_logged_today(log_file) is False
def test_no_entry_for_today(self, tmp_path: Path) -> None:
"""An entry for another day does not count as today's."""
log_file = tmp_path / "log.json"
log_file.write_text(json.dumps({"1999-01-01": {}}))
assert has_logged_today(log_file) is False
def test_valid_hmac(self, tmp_path: Path) -> None:
"""A correctly signed entry for today counts."""
log_file = tmp_path / "log.json"
log_file.write_text(json.dumps({_today(): {"hmac": "sig"}}))
with patch(
@ -78,6 +85,7 @@ class TestHasLoggedToday:
assert has_logged_today(log_file) is True
def test_unsigned_accepted_when_key_unavailable(self, tmp_path: Path) -> None:
"""With no HMAC key on the machine, an unsigned entry is accepted."""
log_file = tmp_path / "log.json"
log_file.write_text(json.dumps({_today(): {}}))
with (
@ -91,6 +99,7 @@ class TestHasLoggedToday:
assert has_logged_today(log_file) is True
def test_rejected_when_key_available(self, tmp_path: Path) -> None:
"""With a key available, an unsigned entry is rejected as tampering."""
log_file = tmp_path / "log.json"
log_file.write_text(json.dumps({_today(): {}}))
with (
@ -104,6 +113,7 @@ class TestHasLoggedToday:
assert has_logged_today(log_file) is False
def test_rejected_when_signed_but_invalid(self, tmp_path: Path) -> None:
"""A signature that does not verify is rejected."""
log_file = tmp_path / "log.json"
log_file.write_text(json.dumps({_today(): {"hmac": "tampered"}}))
with (
@ -121,24 +131,29 @@ class TestIsEarlyBirdPending:
"""Mirrors test_early_bird.py's HMAC branches, for the standalone function."""
def test_missing_file(self, tmp_path: Path) -> None:
"""A missing pending file means no early bird is pending."""
assert is_early_bird_pending(tmp_path / "pending.json") is False
def test_corrupt_json(self, tmp_path: Path) -> None:
"""Unparsable JSON fails closed: nothing pending."""
pending_file = tmp_path / "pending.json"
pending_file.write_text("{bad}")
assert is_early_bird_pending(pending_file) is False
def test_not_a_dict(self, tmp_path: Path) -> None:
"""A JSON list where a dict is expected fails closed."""
pending_file = tmp_path / "pending.json"
pending_file.write_text(json.dumps(["not", "a", "dict"]))
assert is_early_bird_pending(pending_file) is False
def test_stale_date(self, tmp_path: Path) -> None:
"""A pending record from an earlier day no longer applies."""
pending_file = tmp_path / "pending.json"
pending_file.write_text(json.dumps({"date": "2000-01-01", "hmac": "sig"}))
assert is_early_bird_pending(pending_file) is False
def test_valid_hmac(self, tmp_path: Path) -> None:
"""A correctly signed pending record for today counts."""
pending_file = tmp_path / "pending.json"
pending_file.write_text(json.dumps({"date": _today(), "hmac": "sig"}))
with patch(
@ -147,6 +162,7 @@ class TestIsEarlyBirdPending:
assert is_early_bird_pending(pending_file) is True
def test_unsigned_accepted_when_key_unavailable(self, tmp_path: Path) -> None:
"""With no HMAC key, an unsigned pending record is accepted."""
pending_file = tmp_path / "pending.json"
pending_file.write_text(json.dumps({"date": _today()}))
with (
@ -160,6 +176,7 @@ class TestIsEarlyBirdPending:
assert is_early_bird_pending(pending_file) is True
def test_rejected_when_key_available(self, tmp_path: Path) -> None:
"""With a key available, an unsigned pending record is rejected."""
pending_file = tmp_path / "pending.json"
pending_file.write_text(json.dumps({"date": _today()}))
with (
@ -173,6 +190,7 @@ class TestIsEarlyBirdPending:
assert is_early_bird_pending(pending_file) is False
def test_rejected_when_signed_but_invalid(self, tmp_path: Path) -> None:
"""A pending record whose signature fails to verify is rejected."""
pending_file = tmp_path / "pending.json"
pending_file.write_text(json.dumps({"date": _today(), "hmac": "bad"}))
with (
@ -187,89 +205,69 @@ class TestIsEarlyBirdPending:
class TestIsSickDayToday:
"""Reading today's sick-day status out of the sick history."""
"""Thin wrapper around _sick_tracker.is_sick_day."""
def test_true_when_listed(self) -> None:
"""Today present in the history is a sick day."""
history = SickHistory(sick_days=[_today()])
assert is_sick_day_today(history) is True
def test_false_when_not_listed(self) -> None:
"""Today absent from the history is not a sick day."""
history = SickHistory(sick_days=["1999-01-01"])
assert is_sick_day_today(history) is False
def test_explicit_today_override(self) -> None:
"""The `today` argument overrides the real date."""
history = SickHistory(sick_days=["2020-05-05"])
assert is_sick_day_today(history, today="2020-05-05") is True
class TestEarlyBirdWindowOpen:
"""The early-bird window's start/end boundaries, including the extension."""
"""Direct tests for the module-private, deliberately independent reimplementation."""
def test_before_window(self) -> None:
"""Before the start hour the window is shut."""
assert (
_compliance_state._early_bird_window_open(extended=False, local_minutes=299)
is False
)
def test_at_start(self) -> None:
"""The start minute itself is inside the window."""
assert (
_compliance_state._early_bird_window_open(extended=False, local_minutes=300)
is True
)
def test_before_end(self) -> None:
"""A minute before the end is still inside the window."""
assert (
_compliance_state._early_bird_window_open(extended=False, local_minutes=509)
is True
)
def test_at_end_exclusive(self) -> None:
"""The end minute is exclusive: the window is already shut."""
assert (
_compliance_state._early_bird_window_open(extended=False, local_minutes=510)
is False
)
def test_extended_before_end(self) -> None:
"""With the extension earned, the window runs later."""
assert (
_compliance_state._early_bird_window_open(extended=True, local_minutes=539)
is True
)
def test_extended_at_end_exclusive(self) -> None:
"""The extended end is exclusive too."""
assert (
_compliance_state._early_bird_window_open(extended=True, local_minutes=540)
is False
)
class TestDescribeAutoUpgradeOpportunity:
"""The 3 possible outcomes: expired early-bird, sick day, or none."""
def test_expired_early_bird(self) -> None:
result = describe_auto_upgrade_opportunity(
early_bird_pending=True, early_bird_window_open=False, is_sick_day=False
)
assert result == AutoUpgradeOpportunity(
would_attempt=True, via="early_bird_expired", reason=result.reason
)
def test_sick_day(self) -> None:
result = describe_auto_upgrade_opportunity(
early_bird_pending=False, early_bird_window_open=False, is_sick_day=True
)
assert result.would_attempt is True
assert result.via == "sick_day"
def test_sick_day_takes_second_priority_when_pending_still_open(self) -> None:
"""pending+open doesn't count as expired, so sick_day is still checked."""
result = describe_auto_upgrade_opportunity(
early_bird_pending=True, early_bird_window_open=True, is_sick_day=True
)
assert result.via == "sick_day"
def test_none(self) -> None:
result = describe_auto_upgrade_opportunity(
early_bird_pending=False, early_bird_window_open=False, is_sick_day=False
)
assert result.would_attempt is False
assert result.via == "none"

View File

@ -0,0 +1,44 @@
"""Tests for the pure, read-only predicates in _compliance_state.py."""
from __future__ import annotations
from screen_locker._compliance_state import (
AutoUpgradeOpportunity,
describe_auto_upgrade_opportunity,
)
class TestDescribeAutoUpgradeOpportunity:
"""The 3 possible outcomes: expired early-bird, sick day, or none."""
def test_expired_early_bird(self) -> None:
"""Expired early bird."""
result = describe_auto_upgrade_opportunity(
early_bird_pending=True, early_bird_window_open=False, is_sick_day=False
)
assert result == AutoUpgradeOpportunity(
would_attempt=True, via="early_bird_expired", reason=result.reason
)
def test_sick_day(self) -> None:
"""Sick day."""
result = describe_auto_upgrade_opportunity(
early_bird_pending=False, early_bird_window_open=False, is_sick_day=True
)
assert result.would_attempt is True
assert result.via == "sick_day"
def test_sick_day_takes_second_priority_when_pending_still_open(self) -> None:
"""pending+open doesn't count as expired, so sick_day is still checked."""
result = describe_auto_upgrade_opportunity(
early_bird_pending=True, early_bird_window_open=True, is_sick_day=True
)
assert result.via == "sick_day"
def test_none(self) -> None:
"""None."""
result = describe_auto_upgrade_opportunity(
early_bird_pending=False, early_bird_window_open=False, is_sick_day=False
)
assert result.would_attempt is False
assert result.via == "none"

View File

@ -11,10 +11,7 @@ from screen_locker._extra_benefits import (
_load_state,
_save_state,
current_streak,
has_extended_early_bird,
preview_bonus_if_week_ended_now,
process_week_transition,
weekly_shutdown_bonus_hours,
)
if TYPE_CHECKING:
@ -238,74 +235,3 @@ class TestCurrentStreak:
f = tmp_path / "state.json"
f.write_text(json.dumps({"consecutive_5plus_weeks": 5}))
assert current_streak(f) == 5
class TestWeeklyShutdownBonusHours:
"""Tests for weekly_shutdown_bonus_hours."""
def test_returns_zero_when_missing(self, tmp_path: Path) -> None:
"""No state file → 0."""
f = tmp_path / "state.json"
assert weekly_shutdown_bonus_hours(f) == 0
def test_returns_current_week_bonus(self, tmp_path: Path) -> None:
"""Returns the banked bonus for the current ISO week."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week = f"{year}-W{week:02d}"
f = tmp_path / "state.json"
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {current_week: 3}}))
assert weekly_shutdown_bonus_hours(f) == 3
def test_ignores_other_weeks(self, tmp_path: Path) -> None:
"""A bonus banked for a different ISO week is not returned."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2020-W01": 5}}))
assert weekly_shutdown_bonus_hours(f) == 0
class TestPreviewBonusIfWeekEndedNow:
"""Tests for preview_bonus_if_week_ended_now — pure threshold math."""
def test_below_threshold_returns_zero(self) -> None:
"""< 5 workouts: no streak increment, no bonus (line 67 branch)."""
assert preview_bonus_if_week_ended_now(3, current_streak=2) == (0, 0)
def test_at_threshold_awards_streak_and_bonus(self) -> None:
"""Exactly 5 workouts: streak+1, bonus = count - 4."""
assert preview_bonus_if_week_ended_now(5, current_streak=0) == (1, 1)
def test_above_threshold_awards_larger_bonus(self) -> None:
"""7 workouts: bonus = 7 - 4 = 3."""
assert preview_bonus_if_week_ended_now(7, current_streak=0) == (1, 3)
def test_milestone_streak_adds_extra_hour(self) -> None:
"""Streak reaching a multiple of 4 adds +1h on top of the base bonus."""
assert preview_bonus_if_week_ended_now(5, current_streak=3) == (4, 2)
class TestHasExtendedEarlyBird:
"""Tests for has_extended_early_bird."""
def test_returns_false_when_current_week_not_in_list(self, tmp_path: Path) -> None:
"""Current ISO week absent from list → False."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2020-W01"]}))
assert has_extended_early_bird(f) is False
def test_returns_true_when_current_week_is_in_list(self, tmp_path: Path) -> None:
"""Current ISO week present in list → True."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week = f"{year}-W{week:02d}"
f = tmp_path / "state.json"
f.write_text(json.dumps({"extended_early_bird_iso_weeks": [current_week]}))
assert has_extended_early_bird(f) is True
def test_explicit_today_override(self, tmp_path: Path) -> None:
"""An explicit `today` is used instead of the real wall clock."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2024-W01"]}))
fixed_today = datetime(2024, 1, 5, tzinfo=timezone.utc)
assert has_extended_early_bird(f, today=fixed_today) is True
assert has_extended_early_bird(f) is False

View File

@ -0,0 +1,87 @@
"""Tests for _extra_benefits module (streak, shutdown bonus, EB extension)."""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING
from screen_locker._extra_benefits import (
has_extended_early_bird,
preview_bonus_if_week_ended_now,
weekly_shutdown_bonus_hours,
)
if TYPE_CHECKING:
from pathlib import Path
class TestWeeklyShutdownBonusHours:
"""Tests for weekly_shutdown_bonus_hours."""
def test_returns_zero_when_missing(self, tmp_path: Path) -> None:
"""No state file → 0."""
f = tmp_path / "state.json"
assert weekly_shutdown_bonus_hours(f) == 0
def test_returns_current_week_bonus(self, tmp_path: Path) -> None:
"""Returns the banked bonus for the current ISO week."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week = f"{year}-W{week:02d}"
f = tmp_path / "state.json"
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {current_week: 3}}))
assert weekly_shutdown_bonus_hours(f) == 3
def test_ignores_other_weeks(self, tmp_path: Path) -> None:
"""A bonus banked for a different ISO week is not returned."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2020-W01": 5}}))
assert weekly_shutdown_bonus_hours(f) == 0
class TestPreviewBonusIfWeekEndedNow:
"""Tests for preview_bonus_if_week_ended_now — pure threshold math."""
def test_below_threshold_returns_zero(self) -> None:
"""< 5 workouts: no streak increment, no bonus (line 67 branch)."""
assert preview_bonus_if_week_ended_now(3, current_streak=2) == (0, 0)
def test_at_threshold_awards_streak_and_bonus(self) -> None:
"""Exactly 5 workouts: streak+1, bonus = count - 4."""
assert preview_bonus_if_week_ended_now(5, current_streak=0) == (1, 1)
def test_above_threshold_awards_larger_bonus(self) -> None:
"""7 workouts: bonus = 7 - 4 = 3."""
assert preview_bonus_if_week_ended_now(7, current_streak=0) == (1, 3)
def test_milestone_streak_adds_extra_hour(self) -> None:
"""Streak reaching a multiple of 4 adds +1h on top of the base bonus."""
assert preview_bonus_if_week_ended_now(5, current_streak=3) == (4, 2)
class TestHasExtendedEarlyBird:
"""Tests for has_extended_early_bird."""
def test_returns_false_when_current_week_not_in_list(self, tmp_path: Path) -> None:
"""Current ISO week absent from list → False."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2020-W01"]}))
assert has_extended_early_bird(f) is False
def test_returns_true_when_current_week_is_in_list(self, tmp_path: Path) -> None:
"""Current ISO week present in list → True."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week = f"{year}-W{week:02d}"
f = tmp_path / "state.json"
f.write_text(json.dumps({"extended_early_bird_iso_weeks": [current_week]}))
assert has_extended_early_bird(f) is True
def test_explicit_today_override(self, tmp_path: Path) -> None:
"""An explicit `today` is used instead of the real wall clock."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2024-W01"]}))
fixed_today = datetime(2024, 1, 5, tzinfo=timezone.utc)
assert has_extended_early_bird(f, today=fixed_today) is True
assert has_extended_early_bird(f) is False

View File

@ -6,24 +6,18 @@ from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from crdt_sync import GitHubSyncError, RepoNotFoundError
from screen_locker import _manual_push
from screen_locker._manual_push import (
PushResult,
_decode_log,
_encode_log,
_entry_wall_ms,
push_pc_workouts,
records_from_workout_log,
)
if TYPE_CHECKING:
from pathlib import Path
import pytest
_MANUAL = {
"type": "manual_workout",
@ -53,6 +47,7 @@ class TestEncodeDecodeLog:
"""The crdt log blob round-trips."""
def test_round_trips(self, tmp_path: Path) -> None:
"""Round trips."""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
@ -65,6 +60,7 @@ class TestEntryWallMs:
"""The HLC clock is derived from the entry's own timestamp — stably."""
def test_uses_the_entry_timestamp(self) -> None:
"""Uses the entry timestamp."""
expected = int(
datetime(2026, 7, 13, 12, tzinfo=timezone.utc).timestamp() * 1000
)
@ -72,10 +68,12 @@ class TestEntryWallMs:
assert ms == expected
def test_unparsable_timestamp_falls_back_to_midnight(self) -> None:
"""Unparsable timestamp falls back to midnight."""
ms = _entry_wall_ms({"timestamp": "not-a-time"}, "2026-07-13")
assert ms == _entry_wall_ms({"timestamp": "2026-07-13T00:00:00+00:00"}, "x")
def test_missing_timestamp_falls_back_to_midnight(self) -> None:
"""Missing timestamp falls back to midnight."""
expected = int(datetime(2026, 7, 13, tzinfo=timezone.utc).timestamp() * 1000)
assert _entry_wall_ms({}, "2026-07-13") == expected
@ -88,6 +86,7 @@ class TestRecordsFromWorkoutLog:
"""workout_log.json is the single source of truth for what gets pushed."""
def test_derives_a_record_per_counted_workout(self, tmp_path: Path) -> None:
"""Derives a record per counted workout."""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file,
@ -106,6 +105,7 @@ class TestRecordsFromWorkoutLog:
assert set(log) == {"manual:2026-07-13T18:00", "runnerup_verified:2026-07-13"}
def test_payload_carries_kind_and_date(self, tmp_path: Path) -> None:
"""Payload carries kind and date."""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
@ -128,6 +128,7 @@ class TestRecordsFromWorkoutLog:
assert "manual:2026-07-13T18:00" in records_from_workout_log(log_file)
def test_uncounted_types_are_not_pushed(self, tmp_path: Path) -> None:
"""Uncounted types are not pushed."""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file,
@ -136,6 +137,7 @@ class TestRecordsFromWorkoutLog:
assert records_from_workout_log(log_file) == {}
def test_non_dict_workout_data_is_skipped(self, tmp_path: Path) -> None:
"""Non dict workout data is skipped."""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file,
@ -165,130 +167,3 @@ class TestRecordsFromWorkoutLog:
assert _encode_log(records_from_workout_log(log_file)) == _encode_log(
records_from_workout_log(log_file)
)
class TestPushPcWorkouts:
"""Every outcome is reported — never a silent no-op."""
def test_no_token_reports_why(self, tmp_path: Path) -> None:
log_file = tmp_path / "workout_log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
with patch.object(_manual_push, "read_sync_token", return_value=None):
result = push_pc_workouts(log_file)
assert result == PushResult(pushed=False, record_count=0, reason=result.reason)
assert "no sync token" in result.reason
def test_empty_log_reports_why(self, tmp_path: Path) -> None:
log_file = tmp_path / "workout_log.json"
_write_log(log_file, {})
with patch.object(_manual_push, "read_sync_token", return_value="t"):
result = push_pc_workouts(log_file)
assert result.pushed is False
assert result.record_count == 0
assert "no counted workouts" in result.reason
def test_sync_error_reports_why(self, tmp_path: Path) -> None:
log_file = tmp_path / "workout_log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(
_manual_push, "sync_log", side_effect=GitHubSyncError("403 forbidden")
),
):
result = push_pc_workouts(log_file)
assert result.pushed is False
assert result.record_count == 1
assert "403 forbidden" in result.reason
def test_network_error_is_not_blamed_on_the_token(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A network failure must not be reported as a permissions problem.
Regression guard for 2026-07-20: the morning routine runs seconds after
boot/resume, so its pushes failed with the network still down — and the
log asserted "a 403 here means the token lacks contents:write", which
sent an investigation chasing a permissions bug that did not exist.
"""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(
_manual_push,
"sync_log",
side_effect=GitHubSyncError("network error reading x"),
),
caplog.at_level("WARNING"),
):
result = push_pc_workouts(log_file)
assert result.pushed is False
assert "network error reading x" in caplog.text
assert "contents:write" not in caplog.text
def test_repo_not_found_still_points_at_the_token(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The permission hint survives for the error that actually implies it."""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(
_manual_push,
"sync_log",
side_effect=RepoNotFoundError("no access"),
),
caplog.at_level("WARNING"),
):
result = push_pc_workouts(log_file)
assert result.pushed is False
assert "contents:write" in caplog.text
def test_successful_push_reports_count(self, tmp_path: Path) -> None:
log_file = tmp_path / "workout_log.json"
_write_log(
log_file,
{
"2026-07-13": [
_entry(_MANUAL, "2026-07-13T07:25:12+00:00"),
_entry(_RUN, "2026-07-13T23:10:00+00:00"),
]
},
)
fake_sync = MagicMock()
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(_manual_push, "sync_log", fake_sync),
):
result = push_pc_workouts(log_file)
assert result == PushResult(pushed=True, record_count=2, reason="pushed")
assert fake_sync.call_count == 1
def test_pushes_runs_not_just_manuals(self, tmp_path: Path) -> None:
"""Regression: the phone must converge on the PC's runs too."""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file, {"2026-07-12": [_entry(_RUN, "2026-07-12T10:00:00+00:00")]}
)
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(_manual_push, "sync_log", MagicMock()) as fake_sync,
):
push_pc_workouts(log_file)
pushed = fake_sync.call_args.kwargs["local_log"]
assert "runnerup_verified:2026-07-12" in pushed

View File

@ -0,0 +1,168 @@
"""Tests for push_pc_workouts.
Split out of test_manual_push.py for the 250-line cap; the shared _entry /
_write_log helpers stay in that module and are imported here.
"""
# pylint: disable=protected-access
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from crdt_sync import GitHubSyncError, RepoNotFoundError
from screen_locker import _manual_push
from screen_locker._manual_push import (
PushResult,
push_pc_workouts,
)
from screen_locker.tests.test_manual_push import _entry, _write_log
if TYPE_CHECKING:
from pathlib import Path
import pytest
_MANUAL = {
"type": "manual_workout",
"source": "table tennis at Solec",
"start_time": "18:00",
"rpe": 6,
}
_RUN = {
"type": "runnerup_verified",
"source": "Running: 9.8 km in 55 min",
"distance_km": 9.8,
}
class TestPushPcWorkouts:
"""Every outcome is reported — never a silent no-op."""
def test_no_token_reports_why(self, tmp_path: Path) -> None:
"""No token reports why."""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
with patch.object(_manual_push, "read_sync_token", return_value=None):
result = push_pc_workouts(log_file)
assert result == PushResult(pushed=False, record_count=0, reason=result.reason)
assert "no sync token" in result.reason
def test_empty_log_reports_why(self, tmp_path: Path) -> None:
"""Empty log reports why."""
log_file = tmp_path / "workout_log.json"
_write_log(log_file, {})
with patch.object(_manual_push, "read_sync_token", return_value="t"):
result = push_pc_workouts(log_file)
assert result.pushed is False
assert result.record_count == 0
assert "no counted workouts" in result.reason
def test_sync_error_reports_why(self, tmp_path: Path) -> None:
"""Sync error reports why."""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(
_manual_push, "sync_log", side_effect=GitHubSyncError("403 forbidden")
),
):
result = push_pc_workouts(log_file)
assert result.pushed is False
assert result.record_count == 1
assert "403 forbidden" in result.reason
def test_network_error_is_not_blamed_on_the_token(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A network failure must not be reported as a permissions problem.
Regression guard for 2026-07-20: the morning routine runs seconds after
boot/resume, so its pushes failed with the network still down — and the
log asserted "a 403 here means the token lacks contents:write", which
sent an investigation chasing a permissions bug that did not exist.
"""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(
_manual_push,
"sync_log",
side_effect=GitHubSyncError("network error reading x"),
),
caplog.at_level("WARNING"),
):
result = push_pc_workouts(log_file)
assert result.pushed is False
assert "network error reading x" in caplog.text
assert "contents:write" not in caplog.text
def test_repo_not_found_still_points_at_the_token(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The permission hint survives for the error that actually implies it."""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]}
)
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(
_manual_push,
"sync_log",
side_effect=RepoNotFoundError("no access"),
),
caplog.at_level("WARNING"),
):
result = push_pc_workouts(log_file)
assert result.pushed is False
assert "contents:write" in caplog.text
def test_successful_push_reports_count(self, tmp_path: Path) -> None:
"""Successful push reports count."""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file,
{
"2026-07-13": [
_entry(_MANUAL, "2026-07-13T07:25:12+00:00"),
_entry(_RUN, "2026-07-13T23:10:00+00:00"),
]
},
)
fake_sync = MagicMock()
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(_manual_push, "sync_log", fake_sync),
):
result = push_pc_workouts(log_file)
assert result == PushResult(pushed=True, record_count=2, reason="pushed")
assert fake_sync.call_count == 1
def test_pushes_runs_not_just_manuals(self, tmp_path: Path) -> None:
"""Regression: the phone must converge on the PC's runs too."""
log_file = tmp_path / "workout_log.json"
_write_log(
log_file, {"2026-07-12": [_entry(_RUN, "2026-07-12T10:00:00+00:00")]}
)
with (
patch.object(_manual_push, "read_sync_token", return_value="t"),
patch.object(_manual_push, "GitHubSyncClient", MagicMock()),
patch.object(_manual_push, "sync_log", MagicMock()) as fake_sync,
):
push_pc_workouts(log_file)
pushed = fake_sync.call_args.kwargs["local_log"]
assert "runnerup_verified:2026-07-12" in pushed

View File

@ -69,7 +69,10 @@ def _manual_record(draft: ManualWorkoutDraft, date: str = _DATE) -> tuple[str, d
class TestReconstructDraft:
"""ReconstructDraft."""
def test_rebuilds_a_table_tennis_draft(self) -> None:
"""Rebuilds a table tennis draft."""
_, payload = _manual_record(_TT_DRAFT)
draft = reconstruct_draft(payload)
assert draft is not None
@ -77,24 +80,30 @@ class TestReconstructDraft:
assert draft.racket == "Butterfly"
def test_rebuilds_an_other_sport_draft(self) -> None:
"""Rebuilds an other sport draft."""
_, payload = _manual_record(_OTHER_DRAFT)
draft = reconstruct_draft(payload)
assert draft is not None
assert draft.activity_type_other == "squash"
def test_returns_none_when_a_required_field_is_missing(self) -> None:
"""Returns none when a required field is missing."""
_, payload = _manual_record(_TT_DRAFT)
del payload["start_time"]
assert reconstruct_draft(payload) is None
def test_returns_none_on_a_mistyped_field(self) -> None:
"""Returns none on a mistyped field."""
_, payload = _manual_record(_TT_DRAFT)
payload["rpe"] = None
assert reconstruct_draft(payload) is None
class TestIngestManualRecords:
"""IngestManualRecords."""
def test_ingests_a_valid_record(self, tmp_path: Path) -> None:
"""Ingests a valid record."""
log_file = tmp_path / "workout_log.json"
record = _manual_record(_TT_DRAFT)
ingested = ingest_manual_records(log_file, [record], today=_DATE)
@ -107,6 +116,7 @@ class TestIngestManualRecords:
assert count_weekly_workouts(log_file, today=_dt(_DATE)) == 1
def test_is_idempotent(self, tmp_path: Path) -> None:
"""Is idempotent."""
log_file = tmp_path / "workout_log.json"
record = _manual_record(_TT_DRAFT)
ingest_manual_records(log_file, [record], today=_DATE)
@ -114,6 +124,7 @@ class TestIngestManualRecords:
assert again == []
def test_skips_a_non_manual_payload(self, tmp_path: Path) -> None:
"""Skips a non manual payload."""
log_file = tmp_path / "workout_log.json"
assert (
ingest_manual_records(log_file, [("s", {"succeeded": True})], today=_DATE)
@ -121,6 +132,7 @@ class TestIngestManualRecords:
)
def test_skips_a_record_without_a_date(self, tmp_path: Path) -> None:
"""Skips a record without a date."""
log_file = tmp_path / "workout_log.json"
_, payload = _manual_record(_TT_DRAFT)
del payload["date"]
@ -145,18 +157,21 @@ class TestIngestManualRecords:
assert stored[_DATE][1]["workout_data"]["type"] == "manual_workout"
def test_skips_a_malformed_payload(self, tmp_path: Path) -> None:
"""Skips a malformed payload."""
log_file = tmp_path / "workout_log.json"
_, payload = _manual_record(_TT_DRAFT)
payload["rpe"] = None # reconstruct_draft -> None
assert ingest_manual_records(log_file, [("m", payload)], today=_DATE) == []
def test_skips_an_invalid_draft(self, tmp_path: Path) -> None:
"""Skips an invalid draft."""
log_file = tmp_path / "workout_log.json"
_, payload = _manual_record(_TT_DRAFT)
payload["location_name"] = " " # fails validate_manual_workout
assert ingest_manual_records(log_file, [("m", payload)], today=_DATE) == []
def test_skips_when_budget_exhausted(self, tmp_path: Path) -> None:
"""Skips when budget exhausted."""
log_file = tmp_path / "workout_log.json"
record = _manual_record(_TT_DRAFT)
with patch.object(_manual_sync, "is_budget_exhausted", return_value=True):
@ -165,6 +180,7 @@ class TestIngestManualRecords:
def test_writes_unsigned_entry_when_hmac_key_unavailable(
self, tmp_path: Path
) -> None:
"""Writes unsigned entry when HMAC key unavailable."""
log_file = tmp_path / "workout_log.json"
record = _manual_record(_TT_DRAFT)
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None):
@ -179,6 +195,7 @@ class TestIngestManualRecordsCallback:
def test_on_ingested_called_with_entry_and_prior_entries(
self, tmp_path: Path
) -> None:
"""On ingested called with entry and prior entries."""
log_file = tmp_path / "workout_log.json"
log_file.write_text(
json.dumps({_DATE: [{"workout_data": {"type": "phone_verified"}}]})
@ -198,6 +215,7 @@ class TestIngestManualRecordsCallback:
assert prior == [{"workout_data": {"type": "phone_verified"}}]
def test_on_ingested_not_called_when_budget_exhausted(self, tmp_path: Path) -> None:
"""On ingested not called when budget exhausted."""
log_file = tmp_path / "workout_log.json"
record = _manual_record(_TT_DRAFT)
calls: list[tuple[dict, list[dict]]] = []
@ -234,26 +252,6 @@ class TestIngestManualRecordsCallback:
assert calls == []
class TestIngestHelpers:
def test_already_ingested_finds_id_in_per_day_lists(self) -> None:
"""_already_ingested takes normalized ``{date: [entry, ...]}`` and
scans every entry across all days for a matching sync record id."""
logs = {
"a": [],
"b": [
{"workout_data": {"type": "manual_workout"}},
{"workout_data": {"sync_record_id": "x"}},
],
}
assert _manual_sync._already_ingested(logs, "x") is True
assert _manual_sync._already_ingested(logs, "y") is False
def test_already_ingested_ignores_non_dict_workout_data(self) -> None:
"""An entry whose workout_data isn't a dict is skipped, not matched."""
logs = {"b": [{"workout_data": "not-a-dict"}]}
assert _manual_sync._already_ingested(logs, "x") is False
def _dt(date: str) -> object:
from datetime import datetime, timezone

View File

@ -0,0 +1,69 @@
"""Tests for ingesting synced manual workouts into workout_log.json."""
# pylint: disable=protected-access
from __future__ import annotations
from screen_locker import _manual_sync
from screen_locker._manual_workout import (
SPORT_OTHER,
SPORT_TABLE_TENNIS,
ManualWorkoutDraft,
)
_DATE = "2026-07-13"
_TT_DRAFT = ManualWorkoutDraft(
sport=SPORT_TABLE_TENNIS,
start_time="18:00",
end_time="19:30",
location_name="Solec",
transport_method="bike",
cost="40 PLN",
rpe=6,
went_well="Served consistently and moved feet well",
to_improve="Backhand topspin needs more consistency",
overall_feeling="Felt strong focused and in good rhythm",
matches_won=3,
matches_lost=1,
sets_won=7,
sets_lost=4,
racket="Butterfly",
balls="Nittaku 3-star",
)
_OTHER_DRAFT = ManualWorkoutDraft(
sport=SPORT_OTHER,
start_time="09:00",
end_time="10:00",
location_name="Squash club",
transport_method="car",
cost="30 PLN",
rpe=5,
went_well="Moved well and kept long rallies going",
to_improve="Need to volley earlier off the back wall",
overall_feeling="Tired but satisfied with the effort",
activity_type_other="squash",
activity_details="Full-court squash drills and three practice games total",
equipment="racket, goggles",
)
class TestIngestHelpers:
"""The normalization/dedup helpers behind ingest_manual_records."""
def test_already_ingested_finds_id_in_per_day_lists(self) -> None:
"""_already_ingested takes normalized ``{date: [entry, ...]}`` and
scans every entry across all days for a matching sync record id."""
logs = {
"a": [],
"b": [
{"workout_data": {"type": "manual_workout"}},
{"workout_data": {"sync_record_id": "x"}},
],
}
assert _manual_sync._already_ingested(logs, "x") is True
assert _manual_sync._already_ingested(logs, "y") is False
def test_already_ingested_ignores_non_dict_workout_data(self) -> None:
"""An entry whose workout_data isn't a dict is skipped, not matched."""
logs = {"b": [{"workout_data": "not-a-dict"}]}
assert _manual_sync._already_ingested(logs, "x") is False

View File

@ -164,105 +164,3 @@ class TestGetLocalSubnetPrefix:
):
result = locker._get_local_subnet_prefix()
assert result is None
class TestTryWirelessReconnect:
"""Tests for _try_wireless_reconnect method."""
def test_returns_false_when_no_prefix(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when subnet prefix can't be determined."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_get_local_subnet_prefix", return_value=None):
result = locker._try_wireless_reconnect()
assert result is False
def test_returns_true_when_probe_succeeds(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns True when a probe finds the phone."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
patch.object(locker, "_try_adb_connect", return_value=True),
patch.object(locker, "_has_adb_device", return_value=True),
patch(
"screen_locker._phone_verification.socket.create_connection",
) as mock_conn,
):
mock_sock = MagicMock()
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
mock_sock.__exit__ = MagicMock(return_value=False)
mock_conn.return_value = mock_sock
result = locker._try_wireless_reconnect()
assert result is True
def test_returns_false_when_no_probe_succeeds(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when no probe finds the phone."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
patch(
"screen_locker._phone_verification.socket.create_connection",
side_effect=OSError("refused"),
),
):
result = locker._try_wireless_reconnect()
assert result is False
def test_probe_connect_succeeds_but_no_device(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test probe passes socket but adb_connect succeeds without device."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
patch.object(locker, "_try_adb_connect", return_value=True),
patch.object(locker, "_has_adb_device", return_value=False),
patch(
"screen_locker._phone_verification.socket.create_connection",
) as mock_conn,
):
mock_sock = MagicMock()
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
mock_sock.__exit__ = MagicMock(return_value=False)
mock_conn.return_value = mock_sock
result = locker._try_wireless_reconnect()
assert result is False
def test_probe_adb_connect_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test probe where socket connects but adb connect fails."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
patch.object(locker, "_try_adb_connect", return_value=False),
patch(
"screen_locker._phone_verification.socket.create_connection",
) as mock_conn,
):
mock_sock = MagicMock()
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
mock_sock.__exit__ = MagicMock(return_value=False)
mock_conn.return_value = mock_sock
result = locker._try_wireless_reconnect()
assert result is False

View File

@ -41,6 +41,7 @@ class TestValidateJsonData:
def test_stale_when_not_today(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Stale when not today."""
locker = create_locker(mock_tk, tmp_path)
status, message = locker._validate_json_data(
{"date": "2000-01-01", "exercises": ["x"], "duration_seconds": 4000}
@ -51,6 +52,7 @@ class TestValidateJsonData:
def test_no_exercises_when_empty(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""No exercises when empty."""
locker = create_locker(mock_tk, tmp_path)
status, message = locker._validate_json_data(
{"date": _today(), "exercises": [], "duration_seconds": 4000}
@ -61,6 +63,7 @@ class TestValidateJsonData:
def test_too_short_under_minimum(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Too short under minimum."""
locker = create_locker(mock_tk, tmp_path)
# Derive from the ACCEPT bar, not the advertised one: a duration
# merely under 40 may still clear the hidden leeway and verify.
@ -76,6 +79,7 @@ class TestValidateJsonData:
def test_verified_all_succeeded(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Verified all succeeded."""
locker = create_locker(mock_tk, tmp_path)
status, message = locker._validate_json_data(
{
@ -91,6 +95,7 @@ class TestValidateJsonData:
def test_verified_partial_when_not_succeeded(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Verified partial when not succeeded."""
locker = create_locker(mock_tk, tmp_path)
status, message = locker._validate_json_data(
{
@ -110,6 +115,7 @@ class TestScanForHttpServer:
def test_returns_none_without_prefix(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Returns none without prefix."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_get_local_subnet_prefix", return_value=None):
assert locker._scan_for_http_server() is None
@ -117,6 +123,7 @@ class TestScanForHttpServer:
def test_returns_url_when_probe_connects(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Returns URL when probe connects."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
@ -133,6 +140,7 @@ class TestScanForHttpServer:
def test_returns_none_when_all_probes_refused(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Returns none when all probes refused."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
@ -205,75 +213,3 @@ class TestVerifyPhoneWorkoutSyncStaleness:
status, message = locker._verify_phone_workout()
assert status == "stale"
assert "2000-01-01" in message
class TestFetchHttpWorkout:
"""Tests for _fetch_http_workout over the local HTTP server."""
def test_returns_none_when_scan_finds_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_scan_for_http_server", return_value=None):
assert locker._fetch_http_workout() is None
def test_returns_json_on_http_200(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
resp = MagicMock()
resp.status = 200
resp.read.return_value = b'{"date": "2026-06-12", "exercises": ["a"]}'
conn = MagicMock()
conn.getresponse.return_value = resp
with (
patch.object(
locker,
"_scan_for_http_server",
return_value="http://192.168.1.5:8765/workout",
),
patch(
"screen_locker._phone_verification._HTTPConnection",
return_value=conn,
),
):
result = locker._fetch_http_workout()
assert result == {"date": "2026-06-12", "exercises": ["a"]}
def test_returns_none_on_non_ok_status(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
resp = MagicMock()
resp.status = 404
conn = MagicMock()
conn.getresponse.return_value = resp
with (
patch.object(
locker,
"_scan_for_http_server",
return_value="http://192.168.1.5:8765/workout",
),
patch(
"screen_locker._phone_verification._HTTPConnection",
return_value=conn,
),
):
assert locker._fetch_http_workout() is None
def test_returns_none_on_connection_error(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(
locker,
"_scan_for_http_server",
return_value="http://192.168.1.5:8765/workout",
),
patch(
"screen_locker._phone_verification._HTTPConnection",
side_effect=OSError("unreachable"),
),
):
assert locker._fetch_http_workout() is None

View File

@ -0,0 +1,113 @@
"""Tests for phone verification coverage gaps (part 2)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestTryWirelessReconnect:
"""Tests for _try_wireless_reconnect method."""
def test_returns_false_when_no_prefix(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when subnet prefix can't be determined."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_get_local_subnet_prefix", return_value=None):
result = locker._try_wireless_reconnect()
assert result is False
def test_returns_true_when_probe_succeeds(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns True when a probe finds the phone."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
patch.object(locker, "_try_adb_connect", return_value=True),
patch.object(locker, "_has_adb_device", return_value=True),
patch(
"screen_locker._phone_verification.socket.create_connection",
) as mock_conn,
):
mock_sock = MagicMock()
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
mock_sock.__exit__ = MagicMock(return_value=False)
mock_conn.return_value = mock_sock
result = locker._try_wireless_reconnect()
assert result is True
def test_returns_false_when_no_probe_succeeds(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when no probe finds the phone."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
patch(
"screen_locker._phone_verification.socket.create_connection",
side_effect=OSError("refused"),
),
):
result = locker._try_wireless_reconnect()
assert result is False
def test_probe_connect_succeeds_but_no_device(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test probe passes socket but adb_connect succeeds without device."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
patch.object(locker, "_try_adb_connect", return_value=True),
patch.object(locker, "_has_adb_device", return_value=False),
patch(
"screen_locker._phone_verification.socket.create_connection",
) as mock_conn,
):
mock_sock = MagicMock()
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
mock_sock.__exit__ = MagicMock(return_value=False)
mock_conn.return_value = mock_sock
result = locker._try_wireless_reconnect()
assert result is False
def test_probe_adb_connect_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test probe where socket connects but adb connect fails."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
patch.object(locker, "_try_adb_connect", return_value=False),
patch(
"screen_locker._phone_verification.socket.create_connection",
) as mock_conn,
):
mock_sock = MagicMock()
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
mock_sock.__exit__ = MagicMock(return_value=False)
mock_conn.return_value = mock_sock
result = locker._try_wireless_reconnect()
assert result is False

View File

@ -0,0 +1,93 @@
"""Tests for JSON workout validation and HTTP fallback (part 4).
Replaces the obsolete StrongLifts-DB-based ``test_phone_check_unlock.py``.
Covers ``_validate_json_data`` (all status branches) and the HTTP fallback
``_scan_for_http_server`` / ``_fetch_http_workout`` used when ADB is
unavailable. Network is fully mocked — no test touches a real socket.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestFetchHttpWorkout:
"""Tests for _fetch_http_workout over the local HTTP server."""
def test_returns_none_when_scan_finds_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Returns none when scan finds nothing."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_scan_for_http_server", return_value=None):
assert locker._fetch_http_workout() is None
def test_returns_json_on_http_200(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Returns JSON on HTTP 200."""
locker = create_locker(mock_tk, tmp_path)
resp = MagicMock()
resp.status = 200
resp.read.return_value = b'{"date": "2026-06-12", "exercises": ["a"]}'
conn = MagicMock()
conn.getresponse.return_value = resp
with (
patch.object(
locker,
"_scan_for_http_server",
return_value="http://192.168.1.5:8765/workout",
),
patch(
"screen_locker._phone_verification._HTTPConnection",
return_value=conn,
),
):
result = locker._fetch_http_workout()
assert result == {"date": "2026-06-12", "exercises": ["a"]}
def test_returns_none_on_non_ok_status(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Returns none on non ok status."""
locker = create_locker(mock_tk, tmp_path)
resp = MagicMock()
resp.status = 404
conn = MagicMock()
conn.getresponse.return_value = resp
with (
patch.object(
locker,
"_scan_for_http_server",
return_value="http://192.168.1.5:8765/workout",
),
patch(
"screen_locker._phone_verification._HTTPConnection",
return_value=conn,
),
):
assert locker._fetch_http_workout() is None
def test_returns_none_on_connection_error(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Returns none on connection error."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(
locker,
"_scan_for_http_server",
return_value="http://192.168.1.5:8765/workout",
),
patch(
"screen_locker._phone_verification._HTTPConnection",
side_effect=OSError("unreachable"),
),
):
assert locker._fetch_http_workout() is None

View File

@ -2,7 +2,6 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
@ -212,74 +211,3 @@ class TestAdjustShutdownTimeLater:
side_effect=OSError("fail"),
):
assert locker._adjust_shutdown_time_later() is False
class TestSickModeUsedToday:
"""Tests for _sick_mode_used_today method."""
def test_returns_false_when_no_file(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when state file doesn't exist."""
locker = create_locker(mock_tk, tmp_path)
mock_file = MagicMock()
mock_file.exists.return_value = False
with patch(
"screen_locker._shutdown.SICK_DAY_STATE_FILE",
mock_file,
):
assert locker._sick_mode_used_today() is False
def test_returns_true_when_used_today(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns True when state matches today."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
with patch(
"screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
from datetime import datetime, timezone
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
state_file.write_text(json.dumps({"date": today}))
assert locker._sick_mode_used_today() is True
def test_returns_false_when_different_date(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when state is from different date."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
with patch(
"screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
state_file.write_text(json.dumps({"date": "2020-01-01"}))
assert locker._sick_mode_used_today() is False
def test_returns_false_on_json_error(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False on JSONDecodeError."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
with patch(
"screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
state_file.write_text("not json{{{")
assert locker._sick_mode_used_today() is False

View File

@ -0,0 +1,83 @@
"""Tests for shutdown schedule adjustment coverage gaps (part 2)."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestSickModeUsedToday:
"""Tests for _sick_mode_used_today method."""
def test_returns_false_when_no_file(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when state file doesn't exist."""
locker = create_locker(mock_tk, tmp_path)
mock_file = MagicMock()
mock_file.exists.return_value = False
with patch(
"screen_locker._shutdown.SICK_DAY_STATE_FILE",
mock_file,
):
assert locker._sick_mode_used_today() is False
def test_returns_true_when_used_today(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns True when state matches today."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
with patch(
"screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
from datetime import datetime, timezone
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
state_file.write_text(json.dumps({"date": today}))
assert locker._sick_mode_used_today() is True
def test_returns_false_when_different_date(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when state is from different date."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
with patch(
"screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
state_file.write_text(json.dumps({"date": "2020-01-01"}))
assert locker._sick_mode_used_today() is False
def test_returns_false_on_json_error(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False on JSONDecodeError."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
with patch(
"screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
state_file.write_text("not json{{{")
assert locker._sick_mode_used_today() is False

View File

@ -25,6 +25,7 @@ class TestShowRetryAndSickBudget:
def test_shows_sick_button_when_budget_available(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Shows sick button when budget available."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(_sick_tracker, "load_history", return_value=SickHistory()):
locker._show_retry_and_sick("nope")
@ -40,6 +41,7 @@ class TestShowRetryAndSickBudget:
def test_hides_sick_button_when_budget_exhausted(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Hides sick button when budget exhausted."""
locker = create_locker(mock_tk, tmp_path)
full = SickHistory(sick_days=["2026-05-09"] * 99)
with (
@ -59,6 +61,7 @@ class TestProceedToSickCountdownLoadsHistory:
def test_loads_history_when_cache_missing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Loads history when cache missing."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(
@ -83,6 +86,7 @@ class TestFinalizeSickDay:
def test_marks_commitment_broken_and_writes_debt(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Marks commitment broken and writes debt."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {}
history = SickHistory(commitments={"2026-05-10": True})
@ -101,6 +105,7 @@ class TestFinalizeSickDay:
def test_loads_history_when_cache_missing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Loads history when cache missing."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {}
object.__setattr__(locker, "unlock_screen", MagicMock())
@ -126,6 +131,7 @@ class TestClearDebtOnVerifiedWorkout:
def test_returns_none_when_not_phone_verified(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Returns none when not phone verified."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "sick_day"}
assert locker._clear_debt_on_verified_workout() is None
@ -133,6 +139,7 @@ class TestClearDebtOnVerifiedWorkout:
def test_returns_zero_when_no_debt(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Returns zero when no debt."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
with patch.object(
@ -143,6 +150,7 @@ class TestClearDebtOnVerifiedWorkout:
def test_decrements_when_debt_positive(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Decrements when debt positive."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
history = SickHistory(debt=2)
@ -160,6 +168,7 @@ class TestUnlockScreenCommitmentPrompt:
def test_phone_verified_schedules_commitment_prompt(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Phone verified schedules commitment prompt."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
locker.log_file = tmp_path / "log.json"
@ -182,6 +191,7 @@ class TestUnlockScreenCommitmentPrompt:
def test_non_verified_schedules_close_directly(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Non verified schedules close directly."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "sick_day"}
locker.log_file = tmp_path / "log.json"
@ -203,6 +213,7 @@ class TestUnlockScreenCommitmentPrompt:
def test_renders_debt_label_when_positive(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Renders debt label when positive."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
locker.log_file = tmp_path / "log.json"
@ -226,76 +237,3 @@ class TestUnlockScreenCommitmentPrompt:
# ---------------------------------------------------------------------------
# _sick_dialog.py — UI mixin
# ---------------------------------------------------------------------------
class TestShowSickJustification:
"""Tests for the structured sick justification dialog."""
def test_renders_form_without_commitment(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
with patch.object(_sick_tracker, "load_history", return_value=SickHistory()):
locker._show_sick_justification()
assert locker._sick_history_cache.sick_days == []
assert hasattr(locker, "_sick_submit_button")
# Submit button starts enabled (no commitment).
# config(state="disabled") only called for commitment path.
for call in locker._sick_submit_button.first.configure.call_args_list:
assert call.kwargs.get("state") != "disabled"
def test_renders_form_with_commitment_disables_submit(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
history = SickHistory(commitments={"2026-05-10": True})
with (
patch.object(_sick_tracker, "load_history", return_value=history),
patch.object(_sick_tracker, "had_commitment_for_today", return_value=True),
):
locker._show_sick_justification()
# Submit button was disabled and forced-delay started.
states = [
call.kwargs.get("state")
for call in locker._sick_submit_button.first.configure.call_args_list
]
assert "disabled" in states
def test_renders_recent_history_when_present(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
history = SickHistory(
justifications=[
{"date": "2026-05-01", "symptom": "fever", "severity": 7},
],
)
with patch.object(_sick_tracker, "load_history", return_value=history):
locker._show_sick_justification()
labels = [call.kwargs.get("text", "") for call in mock_tk.Label.call_args_list]
assert any("Recent sick days" in t for t in labels)
class TestUpdateCommitmentForcedDelay:
"""Tests for _update_commitment_forced_delay."""
def test_ticks_down(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker._sick_submit_button = MagicMock()
locker._commitment_forced_remaining = 3
locker._update_commitment_forced_delay()
assert locker._commitment_forced_remaining == 2
locker.root.after.assert_called()
def test_enables_when_done(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker._sick_submit_button = MagicMock()
locker._commitment_forced_remaining = 0
locker._update_commitment_forced_delay()
locker._sick_submit_button.config.assert_called_with(
text="SUBMIT", state="normal"
)

View File

@ -0,0 +1,92 @@
"""Tests for sick-budget UI integration, finalize, debt-clear, and dialogs."""
# pylint: disable=protected-access
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker import _sick_tracker
from screen_locker._sick_tracker import SickHistory
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestShowSickJustification:
"""Tests for the structured sick justification dialog."""
def test_renders_form_without_commitment(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Renders form without commitment."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(_sick_tracker, "load_history", return_value=SickHistory()):
locker._show_sick_justification()
assert locker._sick_history_cache.sick_days == []
assert hasattr(locker, "_sick_submit_button")
# Submit button starts enabled (no commitment).
# config(state="disabled") only called for commitment path.
for call in locker._sick_submit_button.first.configure.call_args_list:
assert call.kwargs.get("state") != "disabled"
def test_renders_form_with_commitment_disables_submit(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Renders form with commitment disables submit."""
locker = create_locker(mock_tk, tmp_path)
history = SickHistory(commitments={"2026-05-10": True})
with (
patch.object(_sick_tracker, "load_history", return_value=history),
patch.object(_sick_tracker, "had_commitment_for_today", return_value=True),
):
locker._show_sick_justification()
# Submit button was disabled and forced-delay started.
states = [
call.kwargs.get("state")
for call in locker._sick_submit_button.first.configure.call_args_list
]
assert "disabled" in states
def test_renders_recent_history_when_present(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Renders recent history when present."""
locker = create_locker(mock_tk, tmp_path)
history = SickHistory(
justifications=[
{"date": "2026-05-01", "symptom": "fever", "severity": 7},
],
)
with patch.object(_sick_tracker, "load_history", return_value=history):
locker._show_sick_justification()
labels = [call.kwargs.get("text", "") for call in mock_tk.Label.call_args_list]
assert any("Recent sick days" in t for t in labels)
class TestUpdateCommitmentForcedDelay:
"""Tests for _update_commitment_forced_delay."""
def test_ticks_down(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Ticks down."""
locker = create_locker(mock_tk, tmp_path)
locker._sick_submit_button = MagicMock()
locker._commitment_forced_remaining = 3
locker._update_commitment_forced_delay()
assert locker._commitment_forced_remaining == 2
locker.root.after.assert_called()
def test_enables_when_done(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Enables when done."""
locker = create_locker(mock_tk, tmp_path)
locker._sick_submit_button = MagicMock()
locker._commitment_forced_remaining = 0
locker._update_commitment_forced_delay()
locker._sick_submit_button.config.assert_called_with(
text="SUBMIT", state="normal"
)

View File

@ -242,48 +242,3 @@ class TestRunStatusWorkoutLog:
run_status(locker)
out = capsys.readouterr().out
assert "heat_skip" in out
class TestWeeklyTotalUsesPerWorkoutRule:
"""The final summary must count each verified workout, not days-with-any."""
def test_multiple_same_day_workouts_all_count(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""One day with 2 manual + 3 verified entries = 5, not capped at 1.
Regression test: the summary used to derive its total from a
per-day checkmark count (max 1 per day), undercounting any day
holding more than one counted workout — exactly what multi-workout
days are supposed to support. Also covers manual workouts counting
individually (no once-per-day collapse), same as verified ones.
"""
from datetime import datetime, timezone
today = datetime.now(tz=timezone.utc).astimezone().date().isoformat()
log_file = tmp_path / "log.json"
log_file.write_text(
json.dumps(
{
today: [
{"workout_data": {"type": "manual_workout", "source": "gym"}},
{"workout_data": {"type": "runnerup_verified", "source": "a"}},
{"workout_data": {"type": "phone_verified", "source": "b"}},
{"workout_data": {"type": "runnerup_verified", "source": "c"}},
{"workout_data": {"type": "manual_workout", "source": "gym2"}},
]
}
)
)
eb_file = tmp_path / "eb.json"
# n_filled=0 so the summary is derived purely from before_count.
locker = _make_locker(log_file, n_filled=0)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "Weekly minimum met exactly (5/5)." in out

View File

@ -0,0 +1,63 @@
"""Tests for run_status's weekly-total rule.
Split out of test_status.py for the 250-line cap.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
from screen_locker._status import run_status
from screen_locker.tests.conftest import _make_locker
if TYPE_CHECKING:
from pathlib import Path
import pytest
class TestWeeklyTotalUsesPerWorkoutRule:
"""The final summary must count each verified workout, not days-with-any."""
def test_multiple_same_day_workouts_all_count(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""One day with 2 manual + 3 verified entries = 5, not capped at 1.
Regression test: the summary used to derive its total from a
per-day checkmark count (max 1 per day), undercounting any day
holding more than one counted workout — exactly what multi-workout
days are supposed to support. Also covers manual workouts counting
individually (no once-per-day collapse), same as verified ones.
"""
from datetime import datetime, timezone
today = datetime.now(tz=timezone.utc).astimezone().date().isoformat()
log_file = tmp_path / "log.json"
log_file.write_text(
json.dumps(
{
today: [
{"workout_data": {"type": "manual_workout", "source": "gym"}},
{"workout_data": {"type": "runnerup_verified", "source": "a"}},
{"workout_data": {"type": "phone_verified", "source": "b"}},
{"workout_data": {"type": "runnerup_verified", "source": "c"}},
{"workout_data": {"type": "manual_workout", "source": "gym2"}},
]
}
)
)
eb_file = tmp_path / "eb.json"
# n_filled=0 so the summary is derived purely from before_count.
locker = _make_locker(log_file, n_filled=0)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "Weekly minimum met exactly (5/5)." in out

View File

@ -7,7 +7,6 @@ from unittest.mock import MagicMock, patch
from screen_locker._compliance_state import AutoUpgradeOpportunity
from screen_locker._status_view_verify import (
PhoneCheckOutcome,
_backfill_week_and_apply_bonus,
_verify_phone_then_runnerup,
)
from screen_locker.tests._status_view_helpers import (
@ -23,7 +22,10 @@ from screen_locker.tests._status_view_helpers import (
class TestSectionToday:
"""SectionToday."""
def test_counted_shows_checkmark_and_entry(self, mock_tk: MagicMock) -> None:
"""Counted shows checkmark and entry."""
snap = _snapshot(
today=_day(
entry_types=("phone_verified",),
@ -38,16 +40,19 @@ class TestSectionToday:
assert any(t == "gym" for t in texts)
def test_sick_day_shows_sick_mark(self, mock_tk: MagicMock) -> None:
"""Sick day shows sick mark."""
snap = _snapshot(today=_day(entry_types=(), is_sick_day=True))
_make_window(mock_tk, snap)
assert any("😷" in t and "sick day" in t for t in _texts(mock_tk))
def test_no_entry_shows_dash(self, mock_tk: MagicMock) -> None:
"""No entry shows dash."""
snap = _snapshot(today=_day(entry_types=(), is_sick_day=False))
_make_window(mock_tk, snap)
assert any("no entry yet" in t for t in _texts(mock_tk))
def test_empty_source_adds_no_extra_line(self, mock_tk: MagicMock) -> None:
"""Empty source adds no extra line."""
snap = _snapshot(
today=_day(
entry_types=("phone_verified",),
@ -63,19 +68,24 @@ class TestSectionToday:
class TestSectionWeek:
"""SectionWeek."""
def test_shows_remaining_message_when_under_minimum(
self, mock_tk: MagicMock
) -> None:
"""Shows remaining message when under minimum."""
snap = _snapshot(week=_week(counted_count=2, remaining=2, extra=0))
_make_window(mock_tk, snap)
assert any("Need 2 more this week." in t for t in _texts(mock_tk))
def test_shows_extra_message_when_over_minimum(self, mock_tk: MagicMock) -> None:
"""Shows extra message when over minimum."""
snap = _snapshot(week=_week(counted_count=5, remaining=0, extra=1))
_make_window(mock_tk, snap)
assert any("above the weekly minimum" in t for t in _texts(mock_tk))
def test_shows_neither_message_at_exact_minimum(self, mock_tk: MagicMock) -> None:
"""Shows neither message at exact minimum."""
snap = _snapshot(week=_week(counted_count=4, remaining=0, extra=0))
_make_window(mock_tk, snap)
texts = _texts(mock_tk)
@ -83,6 +93,7 @@ class TestSectionWeek:
assert not any("above the weekly minimum" in t for t in texts)
def test_renders_a_line_per_day(self, mock_tk: MagicMock) -> None:
"""Renders a line per day."""
days = (
_day(
date="2024-01-01",
@ -103,9 +114,12 @@ class TestSectionWeek:
class TestSectionLockExplanation:
"""SectionLockExplanation."""
def test_fired_with_auto_upgrade_and_heat_skip_pending(
self, mock_tk: MagicMock
) -> None:
"""Fired with auto upgrade and heat skip pending."""
snap = _snapshot(
lock_explanation=_lock_explanation(
fired=True,
@ -122,6 +136,7 @@ class TestSectionLockExplanation:
assert any("Pending auto-upgrade: will try phone" in t for t in texts)
def test_not_fired_no_auto_upgrade(self, mock_tk: MagicMock) -> None:
"""Not fired no auto upgrade."""
snap = _snapshot(
lock_explanation=_lock_explanation(fired=False, reason="Skipped.")
)
@ -143,7 +158,10 @@ class TestSectionLockExplanation:
class TestSectionSickBudget:
"""SectionSickBudget."""
def test_exhausted_uses_warning_color(self, mock_tk: MagicMock) -> None:
"""Exhausted uses warning color."""
snap = _snapshot(sick_budget=_sick_budget(used_7d=1, exhausted=True))
window = _make_window(mock_tk, snap)
calls = [
@ -154,6 +172,7 @@ class TestSectionSickBudget:
assert any(c.kwargs.get("fg") == window._colors.danger for c in calls)
def test_not_exhausted_uses_normal_color(self, mock_tk: MagicMock) -> None:
"""Not exhausted uses normal color."""
snap = _snapshot(sick_budget=_sick_budget(used_7d=0, exhausted=False))
window = _make_window(mock_tk, snap)
calls = [
@ -165,17 +184,22 @@ class TestSectionSickBudget:
class TestSectionShutdown:
"""SectionShutdown."""
def test_tonight_present_shows_live_config(self, mock_tk: MagicMock) -> None:
"""Tonight present shows live config."""
snap = _snapshot(shutdown=_shutdown(tonight=(22, 23, 5)))
_make_window(mock_tk, snap)
assert any("22:00" in t and "23:00" in t for t in _texts(mock_tk))
def test_tonight_absent_shows_unavailable_message(self, mock_tk: MagicMock) -> None:
"""Tonight absent shows unavailable message."""
snap = _snapshot(shutdown=_shutdown(tonight=None))
_make_window(mock_tk, snap)
assert any("Live shutdown config unavailable." in t for t in _texts(mock_tk))
def test_shows_rest_of_week_and_next_week_preview(self, mock_tk: MagicMock) -> None:
"""Shows rest of week and next week preview."""
snap = _snapshot()
_make_window(mock_tk, snap)
texts = _texts(mock_tk)
@ -245,50 +269,3 @@ class TestVerifyPhoneThenRunnerup:
result.week_fill_message == "Auto-filled 1 workout from earlier this week."
)
verifier._adjust_shutdown_time_by.assert_not_called()
class TestBackfillWeekAndApplyBonus:
"""The week-scan fallback: backfill unlogged days, apply the earned bonus."""
def test_nothing_filled_returns_none(self) -> None:
verifier = MagicMock()
verifier._scan_and_fill_week_runnerup.return_value = 0
verifier._try_fill_stronglifts_for_week.return_value = 0
with patch(
"screen_locker._status_view_verify.count_weekly_workouts",
return_value=2,
):
assert _backfill_week_and_apply_bonus(verifier) is None
verifier._adjust_shutdown_time_by.assert_not_called()
def test_fill_at_or_below_minimum_earns_no_bonus(self) -> None:
"""Filling up to (not past) the weekly minimum earns no shutdown push."""
verifier = MagicMock()
verifier._scan_and_fill_week_runnerup.return_value = 1
verifier._try_fill_stronglifts_for_week.return_value = 0
with patch(
"screen_locker._status_view_verify.count_weekly_workouts",
side_effect=[3, 4],
):
message = _backfill_week_and_apply_bonus(verifier)
assert message == "Auto-filled 1 workout from earlier this week."
verifier._adjust_shutdown_time_by.assert_not_called()
def test_fill_past_minimum_applies_bonus(self) -> None:
verifier = MagicMock()
verifier._scan_and_fill_week_runnerup.return_value = 1
verifier._try_fill_stronglifts_for_week.return_value = 1
with patch(
"screen_locker._status_view_verify.count_weekly_workouts",
side_effect=[4, 7],
):
message = _backfill_week_and_apply_bonus(verifier)
assert message == (
"Auto-filled 2 workouts from earlier this week. +2h shutdown time."
)
verifier._adjust_shutdown_time_by.assert_called_once_with(2)

View File

@ -0,0 +1,58 @@
"""Tests for the read-only Tkinter status window's rendering (StatusWindow.render)."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from screen_locker._status_view_verify import (
_backfill_week_and_apply_bonus,
)
class TestBackfillWeekAndApplyBonus:
"""The week-scan fallback: backfill unlogged days, apply the earned bonus."""
def test_nothing_filled_returns_none(self) -> None:
"""Nothing filled returns none."""
verifier = MagicMock()
verifier._scan_and_fill_week_runnerup.return_value = 0
verifier._try_fill_stronglifts_for_week.return_value = 0
with patch(
"screen_locker._status_view_verify.count_weekly_workouts",
return_value=2,
):
assert _backfill_week_and_apply_bonus(verifier) is None
verifier._adjust_shutdown_time_by.assert_not_called()
def test_fill_at_or_below_minimum_earns_no_bonus(self) -> None:
"""Filling up to (not past) the weekly minimum earns no shutdown push."""
verifier = MagicMock()
verifier._scan_and_fill_week_runnerup.return_value = 1
verifier._try_fill_stronglifts_for_week.return_value = 0
with patch(
"screen_locker._status_view_verify.count_weekly_workouts",
side_effect=[3, 4],
):
message = _backfill_week_and_apply_bonus(verifier)
assert message == "Auto-filled 1 workout from earlier this week."
verifier._adjust_shutdown_time_by.assert_not_called()
def test_fill_past_minimum_applies_bonus(self) -> None:
"""Fill past minimum applies bonus."""
verifier = MagicMock()
verifier._scan_and_fill_week_runnerup.return_value = 1
verifier._try_fill_stronglifts_for_week.return_value = 1
with patch(
"screen_locker._status_view_verify.count_weekly_workouts",
side_effect=[4, 7],
):
message = _backfill_week_and_apply_bonus(verifier)
assert message == (
"Auto-filled 2 workouts from earlier this week. +2h shutdown time."
)
verifier._adjust_shutdown_time_by.assert_called_once_with(2)

View File

@ -10,9 +10,7 @@ from unittest.mock import patch
from screen_locker._weekly_check import (
_RELAXED_WEEKDAYS,
WEEKLY_WORKOUT_MINIMUM,
count_weekly_workouts,
has_weekly_minimum,
is_relaxed_day,
)
@ -52,31 +50,42 @@ def _make_log(entries: dict[str, str], log_file: Path) -> Path:
class TestIsRelaxedDay:
"""Tue-Thu are optional; Fri-Mon are enforced lock days."""
def test_monday_is_enforced(self) -> None:
"""Monday is an enforced day."""
assert is_relaxed_day(today=_dt(0)) is False
def test_tuesday_is_relaxed(self) -> None:
"""Tuesday is relaxed."""
assert is_relaxed_day(today=_dt(1)) is True
def test_wednesday_is_relaxed(self) -> None:
"""Wednesday is relaxed."""
assert is_relaxed_day(today=_dt(2)) is True
def test_thursday_is_relaxed(self) -> None:
"""Thursday is relaxed."""
assert is_relaxed_day(today=_dt(3)) is True
def test_friday_is_enforced(self) -> None:
"""Friday is an enforced day."""
assert is_relaxed_day(today=_dt(4)) is False
def test_saturday_is_enforced(self) -> None:
"""Saturday is an enforced day."""
assert is_relaxed_day(today=_dt(5)) is False
def test_sunday_is_enforced(self) -> None:
"""Sunday is an enforced day."""
assert is_relaxed_day(today=_dt(6)) is False
def test_relaxed_weekdays_constant_correct(self) -> None:
"""The relaxed set is exactly Tue/Wed/Thu (weekday 1-3)."""
assert frozenset({1, 2, 3}) == _RELAXED_WEEKDAYS
def test_uses_local_time_by_default(self) -> None:
"""Called with no argument it reads local time and still returns a bool."""
result = is_relaxed_day()
assert isinstance(result, bool)
@ -87,50 +96,61 @@ class TestIsRelaxedDay:
class TestCountWeeklyWorkouts:
"""Counting verified workouts inside the current ISO week."""
def test_no_log_file_returns_zero(self, tmp_path: Path) -> None:
"""A missing log counts as zero rather than raising."""
log = tmp_path / "workout_log.json"
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_corrupt_json_returns_zero(self, tmp_path: Path) -> None:
"""Unparsable JSON counts as zero rather than raising."""
log = tmp_path / "workout_log.json"
log.write_text("{not valid json}")
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_oserror_returns_zero(self, tmp_path: Path) -> None:
"""An unreadable log counts as zero rather than raising."""
log = tmp_path / "workout_log.json"
log.write_text("{}")
with patch("builtins.open", side_effect=OSError("no permission")):
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_counts_phone_verified_in_current_week(self, tmp_path: Path) -> None:
"""Two phone-verified days in the same week count as two."""
log = tmp_path / "workout_log.json"
# Mon=2025-05-19, Tue=2025-05-20 both in same week; check on Fri=2025-05-23
_make_log({"2025-05-19": "phone_verified", "2025-05-20": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 2
def test_sick_day_not_counted(self, tmp_path: Path) -> None:
"""A sick day is not a workout."""
log = tmp_path / "workout_log.json"
_make_log({"2025-05-19": "sick_day"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_early_bird_not_counted(self, tmp_path: Path) -> None:
"""An early-bird entry is not a workout."""
log = tmp_path / "workout_log.json"
_make_log({"2025-05-19": "early_bird"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_previous_week_not_counted(self, tmp_path: Path) -> None:
"""Last week's workouts do not count toward this week."""
log = tmp_path / "workout_log.json"
# 2025-05-12 is the Monday of the previous week
_make_log({"2025-05-12": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_future_date_not_counted(self, tmp_path: Path) -> None:
"""A date after today does not count, even in the same week."""
log = tmp_path / "workout_log.json"
# 2025-05-24 is Saturday, checking on Friday 2025-05-23
_make_log({"2025-05-24": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_invalid_date_key_skipped(self, tmp_path: Path) -> None:
"""An unparsable date key is skipped, and valid siblings still count."""
log = tmp_path / "workout_log.json"
data: dict[str, Any] = {
"not-a-date": {
@ -146,12 +166,14 @@ class TestCountWeeklyWorkouts:
assert count_weekly_workouts(log, today=_dt(4)) == 1
def test_non_dict_entry_skipped(self, tmp_path: Path) -> None:
"""An entry that is not a dict is skipped rather than raising."""
log = tmp_path / "workout_log.json"
data: dict[str, Any] = {"2025-05-19": "not-a-dict"}
log.write_text(json.dumps(data))
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_counts_up_to_four(self, tmp_path: Path) -> None:
"""Four workouts in one week count as four (no cap below the minimum)."""
log = tmp_path / "workout_log.json"
_make_log(
{
@ -165,18 +187,21 @@ class TestCountWeeklyWorkouts:
assert count_weekly_workouts(log, today=_dt(4)) == 4
def test_today_counts_if_this_week(self, tmp_path: Path) -> None:
"""Today's own workout counts."""
log = tmp_path / "workout_log.json"
# today is Friday 2025-05-23
_make_log({"2025-05-23": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 1
def test_monday_start_of_week_counted(self, tmp_path: Path) -> None:
"""Monday is the first day of the week, so it counts when checked on Monday."""
log = tmp_path / "workout_log.json"
_make_log({"2025-05-19": "phone_verified"}, log)
# Checking on Monday itself (today=Mon)
assert count_weekly_workouts(log, today=_dt(0)) == 1
def test_mixed_types_only_verified_counted(self, tmp_path: Path) -> None:
"""Only verified workouts count; sick/early-bird entries are ignored."""
log = tmp_path / "workout_log.json"
_make_log(
{
@ -209,51 +234,3 @@ class TestCountWeeklyWorkouts:
# ---------------------------------------------------------------------------
# has_weekly_minimum
# ---------------------------------------------------------------------------
class TestHasWeeklyMinimum:
def test_zero_workouts_is_false(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_three_workouts_is_false(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_four_workouts_is_false(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_five_workouts_is_true(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
"2025-05-23": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is True
def test_weekly_workout_minimum_constant(self) -> None:
assert WEEKLY_WORKOUT_MINIMUM == 5

View File

@ -0,0 +1,73 @@
"""Tests for _weekly_check.has_weekly_minimum.
Split out of test_weekly_check.py for the 250-line cap; the shared _dt /
_make_log helpers stay in that module and are imported here.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from screen_locker._weekly_check import (
WEEKLY_WORKOUT_MINIMUM,
has_weekly_minimum,
)
from screen_locker.tests.test_weekly_check import _dt, _make_log
if TYPE_CHECKING:
from pathlib import Path
class TestHasWeeklyMinimum:
"""has_weekly_minimum counts verified workouts against the weekly bar."""
def test_zero_workouts_is_false(self, tmp_path: Path) -> None:
"""An empty log does not meet the weekly minimum."""
log = tmp_path / "workout_log.json"
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_three_workouts_is_false(self, tmp_path: Path) -> None:
"""Three workouts is short of the five-workout bar."""
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_four_workouts_is_false(self, tmp_path: Path) -> None:
"""Four workouts is still one short of the bar."""
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_five_workouts_is_true(self, tmp_path: Path) -> None:
"""Five workouts meets the bar exactly."""
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
"2025-05-23": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is True
def test_weekly_workout_minimum_constant(self) -> None:
"""The bar is five workouts per ISO week."""
assert WEEKLY_WORKOUT_MINIMUM == 5

View File

@ -8,16 +8,14 @@ and the cross-device merge. The backend selection (``sync_client`` /
from __future__ import annotations
import json
from unittest.mock import MagicMock
from crdt_sync import GitHubSyncError, Hlc, Record
from crdt_sync import Hlc, Record
import pytest
from screen_locker import _workout_sync
from screen_locker.tests._workout_sync_fixtures import (
_manual_payload,
_manual_record_dict,
_multi_device_client,
_record_json,
_session_payload,
_session_record_dict,
@ -33,14 +31,19 @@ from screen_locker.tests._workout_sync_fixtures import (
class TestReadSyncToken:
"""ReadSyncToken."""
def test_returns_none_when_file_is_missing(self) -> None:
"""Returns none when file is missing."""
assert _workout_sync.read_sync_token() is None
def test_returns_none_when_file_is_empty(self) -> None:
"""Returns none when file is empty."""
_workout_sync.SYNC_TOKEN_FILE.write_text(" \n")
assert _workout_sync.read_sync_token() is None
def test_returns_the_stripped_token(self) -> None:
"""Returns the stripped token."""
_workout_sync.SYNC_TOKEN_FILE.write_text(" abc123 \n")
assert _workout_sync.read_sync_token() == "abc123"
@ -49,12 +52,15 @@ class TestIsSessionPayload:
"""Sessions are identified by SHAPE (an ``exercises`` list), not ``kind``."""
def test_true_for_a_dict_with_an_exercises_list(self) -> None:
"""True for a dict with an exercises list."""
assert _workout_sync._is_session_payload(_session_payload()) is True
def test_true_even_with_an_empty_exercises_list(self) -> None:
"""True even with an empty exercises list."""
assert _workout_sync._is_session_payload({"exercises": []}) is True
def test_false_for_a_manual_workout(self) -> None:
"""False for a manual workout."""
assert _workout_sync._is_session_payload(_manual_payload()) is False
def test_false_for_verified_records_sharing_the_same_log(self) -> None:
@ -63,24 +69,33 @@ class TestIsSessionPayload:
assert _workout_sync._is_session_payload({"kind": "phone_verified"}) is False
def test_false_when_exercises_is_not_a_list(self) -> None:
"""False when exercises is not a list."""
assert _workout_sync._is_session_payload({"exercises": "Squat"}) is False
def test_false_for_a_non_dict_payload(self) -> None:
"""False for a non dict payload."""
assert _workout_sync._is_session_payload("not-a-dict") is False
assert _workout_sync._is_session_payload(None) is False
class TestIsManualPayload:
"""IsManualPayload."""
def test_true_for_manual_kind(self) -> None:
"""True for manual kind."""
assert _workout_sync._is_manual_payload({"kind": "manual_workout"}) is True
def test_false_for_session_or_non_dict(self) -> None:
"""False for session or non dict."""
assert _workout_sync._is_manual_payload(_session_payload()) is False
assert _workout_sync._is_manual_payload("not-a-dict") is False
class TestSessionRecords:
"""SessionRecords."""
def test_returns_only_session_records(self) -> None:
"""Returns only session records."""
log = {
"s": _session_record_dict("session:1", _session_payload()),
"m": _manual_record_dict("manual:1", _manual_payload()),
@ -90,6 +105,7 @@ class TestSessionRecords:
assert list(result) == ["session:1"]
def test_skips_records_without_a_payload_field(self) -> None:
"""Skips records without a payload field."""
no_payload = Record(
id="a",
fields={"other": (1, Hlc(wall_time_ms=1, counter=0, node_id="phone"))},
@ -98,21 +114,27 @@ class TestSessionRecords:
assert _workout_sync._session_records(json.dumps(log)) == {}
def test_raises_type_error_when_top_level_is_not_an_object(self) -> None:
"""Raises type error when top level is not an object."""
with pytest.raises(TypeError, match="not a JSON object"):
_workout_sync._session_records(json.dumps([1, 2, 3]))
def test_raises_value_error_for_invalid_json(self) -> None:
"""Raises value error for invalid JSON."""
with pytest.raises(ValueError, match="Expecting property name"):
_workout_sync._session_records("{not valid json")
def test_raises_key_error_for_a_malformed_record(self) -> None:
"""Raises key error for a malformed record."""
log = {"a": {"id": "a"}} # missing "fields"
with pytest.raises(KeyError):
_workout_sync._session_records(json.dumps(log))
class TestManualRecords:
"""ManualRecords."""
def test_returns_only_manual_records(self) -> None:
"""Returns only manual records."""
log = {
"s": _record_json(_session_payload()),
"m": _manual_record_dict("manual:1", _manual_payload()),
@ -121,6 +143,7 @@ class TestManualRecords:
assert list(result) == ["manual:1"]
def test_skips_records_without_a_payload_field(self) -> None:
"""Skips records without a payload field."""
no_payload = Record(
id="a",
fields={"other": (1, Hlc(wall_time_ms=1, counter=0, node_id="phone"))},
@ -129,150 +152,6 @@ class TestManualRecords:
assert _workout_sync._manual_records(json.dumps(log)) == {}
def test_raises_type_error_when_top_level_is_not_an_object(self) -> None:
"""Raises type error when top level is not an object."""
with pytest.raises(TypeError, match="not a JSON object"):
_workout_sync._manual_records(json.dumps([1, 2, 3]))
class TestMergeDeviceRecords:
"""The walk over EVERY ``devices/<id>/log.json``, not one hardcoded path."""
def test_merges_records_across_every_device(self) -> None:
client = _multi_device_client(
{
"phone": json.dumps(
{"a": _session_record_dict("session:a", _session_payload())}
),
"pc": json.dumps(
{"b": _session_record_dict("session:b", _session_payload())}
),
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert sorted(merged) == ["session:a", "session:b"]
def test_dedups_the_same_id_keeping_the_highest_clock(self) -> None:
client = _multi_device_client(
{
"phone": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="OLD"),
wall_time_ms=100,
)
}
),
"pc": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="NEW"),
wall_time_ms=200,
)
}
),
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert len(merged) == 1
assert merged["session:a"][0]["note"] == "NEW"
def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None:
client = _multi_device_client(
{
"phone": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="NEW"),
wall_time_ms=200,
)
}
),
"pc": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="OLD"),
wall_time_ms=100,
)
}
),
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert merged["session:a"][0]["note"] == "NEW"
def test_skips_a_corrupt_device_log(self) -> None:
client = _multi_device_client(
{
"good": json.dumps(
{"a": _session_record_dict("session:a", _session_payload())}
),
"corrupt": "{not json",
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert list(merged) == ["session:a"]
def test_skips_an_unreachable_device(self) -> None:
client = _multi_device_client(
{
"good": json.dumps(
{"a": _session_record_dict("session:a", _session_payload())}
),
"gone": GitHubSyncError("404"),
"empty": None,
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert list(merged) == ["session:a"]
def test_returns_empty_on_a_listing_failure_when_not_strict(self) -> None:
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert merged == {}
def test_reraises_on_a_listing_failure_when_strict(self) -> None:
"""Callers separating "nothing to sync" from "sync broke" need the raise."""
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with pytest.raises(GitHubSyncError, match="offline"):
_workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions", strict=True
)
def test_warns_about_a_listing_failure(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""A total pull failure must never be silent."""
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with caplog.at_level("WARNING"):
_workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert "offline" in caplog.text
def test_warns_about_a_skipped_device(
self, caplog: pytest.LogCaptureFixture
) -> None:
client = _multi_device_client({"gone": GitHubSyncError("404")})
with caplog.at_level("WARNING"):
_workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert "SKIPPING that device" in caplog.text

View File

@ -0,0 +1,173 @@
"""Tests for the phone-workout sync pull (crdt-sync transport).
Covers the token read, the payload predicates, the per-log record extractors
and the cross-device merge. The backend selection (``sync_client`` /
``remote_client``) and the two public pulls live in ``_part2``.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock
from crdt_sync import GitHubSyncError
import pytest
from screen_locker import _workout_sync
from screen_locker.tests._workout_sync_fixtures import (
_multi_device_client,
_session_payload,
_session_record_dict,
)
class TestMergeDeviceRecords:
"""The walk over EVERY ``devices/<id>/log.json``, not one hardcoded path."""
def test_merges_records_across_every_device(self) -> None:
"""Merges records across every device."""
client = _multi_device_client(
{
"phone": json.dumps(
{"a": _session_record_dict("session:a", _session_payload())}
),
"pc": json.dumps(
{"b": _session_record_dict("session:b", _session_payload())}
),
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert sorted(merged) == ["session:a", "session:b"]
def test_dedups_the_same_id_keeping_the_highest_clock(self) -> None:
"""Dedups the same ID keeping the highest clock."""
client = _multi_device_client(
{
"phone": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="OLD"),
wall_time_ms=100,
)
}
),
"pc": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="NEW"),
wall_time_ms=200,
)
}
),
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert len(merged) == 1
assert merged["session:a"][0]["note"] == "NEW"
def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None:
"""Ignores a lower clock duplicate seen later."""
client = _multi_device_client(
{
"phone": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="NEW"),
wall_time_ms=200,
)
}
),
"pc": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="OLD"),
wall_time_ms=100,
)
}
),
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert merged["session:a"][0]["note"] == "NEW"
def test_skips_a_corrupt_device_log(self) -> None:
"""Skips a corrupt device log."""
client = _multi_device_client(
{
"good": json.dumps(
{"a": _session_record_dict("session:a", _session_payload())}
),
"corrupt": "{not json",
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert list(merged) == ["session:a"]
def test_skips_an_unreachable_device(self) -> None:
"""Skips an unreachable device."""
client = _multi_device_client(
{
"good": json.dumps(
{"a": _session_record_dict("session:a", _session_payload())}
),
"gone": GitHubSyncError("404"),
"empty": None,
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert list(merged) == ["session:a"]
def test_returns_empty_on_a_listing_failure_when_not_strict(self) -> None:
"""Returns empty on a listing failure when not strict."""
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert merged == {}
def test_reraises_on_a_listing_failure_when_strict(self) -> None:
"""Callers separating "nothing to sync" from "sync broke" need the raise."""
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with pytest.raises(GitHubSyncError, match="offline"):
_workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions", strict=True
)
def test_warns_about_a_listing_failure(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""A total pull failure must never be silent."""
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with caplog.at_level("WARNING"):
_workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert "offline" in caplog.text
def test_warns_about_a_skipped_device(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Warns about a skipped device."""
client = _multi_device_client({"gone": GitHubSyncError("404")})
with caplog.at_level("WARNING"):
_workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert "SKIPPING that device" in caplog.text