mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 13:07:43 +01:00
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:
parent
ee8f8bcf3a
commit
a0d78bc719
@ -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
|
||||
|
||||
115
screen_locker/tests/test_adb_and_phone_part2.py
Normal file
115
screen_locker/tests/test_adb_and_phone_part2.py
Normal 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
|
||||
@ -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"
|
||||
|
||||
44
screen_locker/tests/test_compliance_state_part3.py
Normal file
44
screen_locker/tests/test_compliance_state_part3.py
Normal 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"
|
||||
@ -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
|
||||
|
||||
87
screen_locker/tests/test_extra_benefits_part2.py
Normal file
87
screen_locker/tests/test_extra_benefits_part2.py
Normal 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
|
||||
@ -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
|
||||
|
||||
168
screen_locker/tests/test_manual_push_part2.py
Normal file
168
screen_locker/tests/test_manual_push_part2.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
69
screen_locker/tests/test_manual_sync_part2.py
Normal file
69
screen_locker/tests/test_manual_sync_part2.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
113
screen_locker/tests/test_phone_verification_part6.py
Normal file
113
screen_locker/tests/test_phone_verification_part6.py
Normal 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
|
||||
93
screen_locker/tests/test_phone_verification_part7.py
Normal file
93
screen_locker/tests/test_phone_verification_part7.py
Normal 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
|
||||
@ -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
|
||||
|
||||
83
screen_locker/tests/test_shutdown_part5.py
Normal file
83
screen_locker/tests/test_shutdown_part5.py
Normal 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
|
||||
@ -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"
|
||||
)
|
||||
|
||||
92
screen_locker/tests/test_sick_features_part3.py
Normal file
92
screen_locker/tests/test_sick_features_part3.py
Normal 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"
|
||||
)
|
||||
@ -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
|
||||
|
||||
63
screen_locker/tests/test_status_part3.py
Normal file
63
screen_locker/tests/test_status_part3.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
58
screen_locker/tests/test_status_view_part4.py
Normal file
58
screen_locker/tests/test_status_view_part4.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
73
screen_locker/tests/test_weekly_check_part2.py
Normal file
73
screen_locker/tests/test_weekly_check_part2.py
Normal 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
|
||||
@ -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
|
||||
|
||||
173
screen_locker/tests/test_workout_sync_part4.py
Normal file
173
screen_locker/tests/test_workout_sync_part4.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user