Split the last 8 test modules; scope pylint's test-inherent checks
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run

Splits test_screen_lock_coverage_part2 via a shared _unlock_helpers.setup_unlock
factory rather than duplicating the fixture, and finishes the remaining
runnerup/manual-workout/status-view test modules under the 250-line cap.

Also splits the pylint hook in two. protected-access, unused-argument and
duplicate-code are how pytest and unit-testing private mixins work, not
defects, and a commit made entirely of test files could never clear the 8.0
batch score with them counted. Source keeps the full rule set — verified that
an injected protected-access is still reported there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 19:40:32 +02:00
parent dd8296fd3f
commit 3476dd3afd
17 changed files with 1460 additions and 1072 deletions

View File

@ -92,7 +92,22 @@ repos:
- repo: https://github.com/pylint-dev/pylint
rev: v3.3.2
hooks:
# Two hooks, because the three checks below are not defects in a test
# suite -- they are how pytest and unit-testing private mixins work --
# while in SOURCE they are real findings that must still fail:
# protected-access tests drive private mixin methods directly; those
# methods ARE the unit under test (ruff already
# exempts the same thing via SLF001 for tests).
# unused-argument pytest fixtures must be REQUESTED in the signature
# to take effect but are never referenced in the body.
# duplicate-code near-identical `with patch(...)` preambles; a test
# should read top-to-bottom without chasing a fixture.
# Splitting also removes the old failure mode where the score depended on
# what else happened to be staged alongside. See
# todo_fix_pylint_10_prompt.md for raising both to a hard 10.00.
- id: pylint
name: pylint (source)
exclude: ^screen_locker/tests/
args:
- --rcfile=pyproject.toml
- --fail-under=8.0
@ -103,6 +118,21 @@ repos:
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.6.0#subdirectory=crdt-sync
- mcp>=2,<3
- pydantic
- id: pylint
name: pylint (tests)
alias: pylint-tests
files: ^screen_locker/tests/
args:
- --rcfile=pyproject.toml
- --fail-under=8.0
- --jobs=4
- --disable=protected-access,unused-argument,duplicate-code
additional_dependencies:
- pytest
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.6.0#subdirectory=crdt-sync
- mcp>=2,<3
- pydantic
- repo: https://github.com/PyCQA/bandit
rev: 1.7.10

View File

@ -0,0 +1,73 @@
"""Shared factory for unlock_screen tests.
Extracted from test_screen_lock_coverage_part2.py when it was split for the
250-line cap, so both halves build their locker the same way. Named with a
leading underscore so pre-commit's name-tests-test hook skips it.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
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
def setup_unlock(
mock_tk: MagicMock,
tmp_path: Path,
weekly_count: int = 5,
streak: int = 0,
adjust_ok: bool = True,
seed_today_type: str | None = None,
):
"""Create a locker ready to call unlock_screen.
``seed_today_type`` pre-logs a counted workout for today under a
different ``workout_id``, so the unlock's own verified workout is an
ADDITIONAL same-day one — the case that now earns the +1h bonus.
"""
log_file = tmp_path / "workout_log.json"
if seed_today_type is None:
log_file.write_text("{}")
else:
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
log_file.write_text(
json.dumps(
{
today: [
{
"timestamp": f"{today}T06:00:00+00:00",
"workout_data": {"type": seed_today_type},
"workout_id": f"{seed_today_type}:{today}",
}
]
}
)
)
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {"type": "phone_verified"}
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
object.__setattr__(
locker,
"_adjust_shutdown_time_by",
MagicMock(return_value=adjust_ok),
)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(return_value=(22, 22, 5)),
)
return locker

View File

@ -4,16 +4,10 @@
from __future__ import annotations
import pathlib
import re
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker import _manual_workout, _sick_tracker
from screen_locker._constants import (
MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
)
from screen_locker._log_mixin import RecordResult
from screen_locker._sick_tracker import SickHistory
from screen_locker.tests.conftest import create_locker
@ -38,6 +32,7 @@ class TestShowRetryAndManualWorkoutBudget:
def test_shows_button_when_budget_available(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Shows button when budget available."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(_sick_tracker, "load_history", return_value=SickHistory()),
@ -50,6 +45,7 @@ class TestShowRetryAndManualWorkoutBudget:
def test_hides_button_when_budget_exhausted(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Hides button when budget exhausted."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(_sick_tracker, "load_history", return_value=SickHistory()),
@ -70,6 +66,7 @@ class TestOnManualWorkoutSaved:
def test_sets_workout_data_and_schedules_unlock(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Sets workout data and schedules unlock."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "unlock_screen", MagicMock())
object.__setattr__(locker.root, "after", MagicMock())
@ -88,288 +85,8 @@ class TestOnManualWorkoutCancelled:
def test_returns_to_phone_check(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Returns to phone check."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_start_phone_check", MagicMock())
locker._on_manual_workout_cancelled()
locker._start_phone_check.assert_called_once()
class TestApplyWorkoutCredit:
"""Tests for ScreenLocker._apply_workout_credit under the multi-per-day rule.
Credit is now driven by the :class:`RecordResult` the write chokepoint
returns (``appended`` + ``prior_entries``), not a separate
``_was_already_counted_today`` probe (removed).
"""
def test_skips_credit_when_duplicate_already_logged_today(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""save_workout_log reports a duplicate (appended=False) → no new credit."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "manual_workout"}
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=False, prior_entries=[])),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=0)
)
result = locker._apply_workout_credit()
assert result.already_counted_today is True
assert result.shutdown_adjusted is False
assert result.new_debt is None
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._clear_debt_on_verified_workout.assert_not_called()
locker.save_workout_log.assert_called_once() # log entry write attempted
def test_first_workout_of_day_gets_base_shutdown_push(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""First counted workout of the day (empty prior_entries) → base +2h push."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "manual_workout"}
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=[])),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=2)
)
result = locker._apply_workout_credit()
assert result.already_counted_today is False
assert result.shutdown_adjusted is True
assert result.new_debt == 2
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_called_once()
def test_additional_manual_workout_earns_plus_one_hour(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A second manual workout on a day that already counted → +1h extra.
Manual workouts now stack intra-day shutdown credit exactly like
verified ones — the rate budget (not this stacking rule) is the sole
limiter on manual workouts. Mirrors
``test_additional_verified_workout_earns_plus_one_hour``.
"""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "manual_workout"}
prior = [{"workout_data": {"type": "manual_workout"}}]
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=[(21, 21, 5), (22, 22, 5)]),
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
with patch(
"screen_locker._workout_credit.count_weekly_workouts", return_value=5
):
result = locker._apply_workout_credit()
assert result.weekly_count == 5
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 1
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_called_once_with(1)
def test_additional_verified_workout_earns_plus_one_hour(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""An additional same-day VERIFIED workout earns the +1h extra bonus.
prior_entries already holds a counted workout, so this isn't the first
of the day; because the new type is verified it takes the +1h branch,
and extra_bonus_delta reflects the shutdown-config hour delta.
"""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=[(21, 21, 5), (22, 22, 5)]),
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
with patch(
"screen_locker._workout_credit.count_weekly_workouts", return_value=5
):
result = locker._apply_workout_credit()
assert result.weekly_count == 5
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 1
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_called_once_with(1)
def test_additional_workout_unreadable_shutdown_config_earns_no_bonus(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Additional same-day workout, but the shutdown config can't be read
(old_cfg is None) → the +1h branch is entered but bails out before
ever attempting the adjustment."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=None)
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
result = locker._apply_workout_credit()
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_not_called()
def test_additional_workout_of_uncounted_type_earns_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A workout whose type isn't in COUNTED_WORKOUT_TYPES (e.g.
``early_bird``) and isn't the first counted entry of the day →
neither the base push nor the +1h branch fires; the shutdown config
is never even consulted."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "early_bird"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(21, 21, 5))
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
result = locker._apply_credit_for_written_entry(prior)
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._read_shutdown_config.assert_not_called()
locker._adjust_shutdown_time_by.assert_not_called()
def test_skips_save_for_sick_day_type(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "sick_day"}
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
object.__setattr__(locker, "save_workout_log", MagicMock())
result = locker._apply_workout_credit()
assert result.already_counted_today is False
assert result.shutdown_adjusted is False
locker.save_workout_log.assert_not_called()
class TestCrossLanguageBudgetConstants:
"""Pins the budget caps against the Dart phone app's own source file.
The wire-format fixture in test_manual_workout.py pins payload key NAMES,
but the caps never travel on the wire, so it cannot catch them drifting —
and they did: 298daf0 raised Python 5 -> 10 while touching zero Dart files,
leaving the phone at 5 for weeks. The stricter side silently blocks a
workout the other accepts.
So this READS THE DART FILE rather than asserting a hardcoded literal on
each side. A test that compares a Python constant to a Python literal is
only a guard against a local edit; it stays green when the other language
drifts, which is the failure that actually happened. One test, either
direction.
"""
@staticmethod
def _dart_const(name: str) -> int:
"""Return the int assigned to `const int <name>` in the Dart model."""
source = DART_MANUAL_WORKOUT.read_text(encoding="utf-8")
match = re.search(rf"const int {name} = (\d+);", source)
assert match is not None, (
f"{name} not found in {DART_MANUAL_WORKOUT} — it was renamed or "
"removed; update this guard so the caps stay pinned."
)
return int(match.group(1))
def test_dart_model_file_exists(self) -> None:
"""A moved/renamed Dart model must fail loudly, not skip the guard."""
assert DART_MANUAL_WORKOUT.is_file(), (
f"{DART_MANUAL_WORKOUT} is missing; the budget caps are no longer "
"pinned across languages."
)
def test_per_7_days_matches_dart_mirror(self) -> None:
"""The 7-day cap must equal kManualWorkoutBudgetPer7Days."""
assert (
self._dart_const("kManualWorkoutBudgetPer7Days")
== MANUAL_WORKOUT_BUDGET_PER_7_DAYS
)
def test_per_30_days_matches_dart_mirror(self) -> None:
"""The 30-day cap must equal kManualWorkoutBudgetPer30Days."""
assert (
self._dart_const("kManualWorkoutBudgetPer30Days")
== MANUAL_WORKOUT_BUDGET_PER_30_DAYS
)

View File

@ -0,0 +1,258 @@
"""Tests for manual-workout integration in _ui_flows.py and screen_lock.py."""
# pylint: disable=protected-access
from __future__ import annotations
import pathlib
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker._log_mixin import RecordResult
from screen_locker.tests.conftest import create_locker
# The Dart mirror of the budget caps; read at test time so drift on
# EITHER side fails, not just a local edit to the Python constants.
DART_MANUAL_WORKOUT = (
pathlib.Path(__file__).resolve().parents[2]
/ "stronglift_replacement"
/ "workout_app"
/ "lib"
/ "models"
/ "manual_workout.dart"
)
if TYPE_CHECKING:
from pathlib import Path
class TestApplyWorkoutCredit:
"""Tests for ScreenLocker._apply_workout_credit under the multi-per-day rule.
Credit is now driven by the :class:`RecordResult` the write chokepoint
returns (``appended`` + ``prior_entries``), not a separate
``_was_already_counted_today`` probe (removed).
"""
def test_skips_credit_when_duplicate_already_logged_today(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""save_workout_log reports a duplicate (appended=False) → no new credit."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "manual_workout"}
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=False, prior_entries=[])),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=0)
)
result = locker._apply_workout_credit()
assert result.already_counted_today is True
assert result.shutdown_adjusted is False
assert result.new_debt is None
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._clear_debt_on_verified_workout.assert_not_called()
locker.save_workout_log.assert_called_once() # log entry write attempted
def test_first_workout_of_day_gets_base_shutdown_push(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""First counted workout of the day (empty prior_entries) → base +2h push."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "manual_workout"}
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=[])),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=2)
)
result = locker._apply_workout_credit()
assert result.already_counted_today is False
assert result.shutdown_adjusted is True
assert result.new_debt == 2
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_called_once()
def test_additional_manual_workout_earns_plus_one_hour(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A second manual workout on a day that already counted → +1h extra.
Manual workouts now stack intra-day shutdown credit exactly like
verified ones — the rate budget (not this stacking rule) is the sole
limiter on manual workouts. Mirrors
``test_additional_verified_workout_earns_plus_one_hour``.
"""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "manual_workout"}
prior = [{"workout_data": {"type": "manual_workout"}}]
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=[(21, 21, 5), (22, 22, 5)]),
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
with patch(
"screen_locker._workout_credit.count_weekly_workouts", return_value=5
):
result = locker._apply_workout_credit()
assert result.weekly_count == 5
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 1
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_called_once_with(1)
def test_additional_verified_workout_earns_plus_one_hour(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""An additional same-day VERIFIED workout earns the +1h extra bonus.
prior_entries already holds a counted workout, so this isn't the first
of the day; because the new type is verified it takes the +1h branch,
and extra_bonus_delta reflects the shutdown-config hour delta.
"""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=[(21, 21, 5), (22, 22, 5)]),
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
with patch(
"screen_locker._workout_credit.count_weekly_workouts", return_value=5
):
result = locker._apply_workout_credit()
assert result.weekly_count == 5
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 1
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_called_once_with(1)
def test_additional_workout_unreadable_shutdown_config_earns_no_bonus(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Additional same-day workout, but the shutdown config can't be read
(old_cfg is None) → the +1h branch is entered but bails out before
ever attempting the adjustment."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker,
"save_workout_log",
MagicMock(return_value=RecordResult(appended=True, prior_entries=prior)),
)
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=None)
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
result = locker._apply_workout_credit()
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_not_called()
def test_additional_workout_of_uncounted_type_earns_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""A workout whose type isn't in COUNTED_WORKOUT_TYPES (e.g.
``early_bird``) and isn't the first counted entry of the day →
neither the base push nor the +1h branch fires; the shutdown config
is never even consulted."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "early_bird"}
prior = [{"workout_data": {"type": "phone_verified"}}]
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(21, 21, 5))
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
result = locker._apply_credit_for_written_entry(prior)
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 0
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._read_shutdown_config.assert_not_called()
locker._adjust_shutdown_time_by.assert_not_called()
def test_skips_save_for_sick_day_type(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Skips save for sick day type."""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "sick_day"}
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
object.__setattr__(locker, "save_workout_log", MagicMock())
result = locker._apply_workout_credit()
assert result.already_counted_today is False
assert result.shutdown_adjusted is False
locker.save_workout_log.assert_not_called()

View File

@ -0,0 +1,74 @@
"""The manual-workout budget constants must match across Python and Dart.
Split out of test_manual_workout_features_part2.py for the 250-line cap.
"""
from __future__ import annotations
import pathlib
import re
from screen_locker._constants import (
MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
)
# The Dart mirror of the budget caps; read at test time so drift on
# EITHER side fails, not just a local edit to the Python constants.
DART_MANUAL_WORKOUT = (
pathlib.Path(__file__).resolve().parents[2]
/ "stronglift_replacement"
/ "workout_app"
/ "lib"
/ "models"
/ "manual_workout.dart"
)
class TestCrossLanguageBudgetConstants:
"""Pins the budget caps against the Dart phone app's own source file.
The wire-format fixture in test_manual_workout.py pins payload key NAMES,
but the caps never travel on the wire, so it cannot catch them drifting —
and they did: 298daf0 raised Python 5 -> 10 while touching zero Dart files,
leaving the phone at 5 for weeks. The stricter side silently blocks a
workout the other accepts.
So this READS THE DART FILE rather than asserting a hardcoded literal on
each side. A test that compares a Python constant to a Python literal is
only a guard against a local edit; it stays green when the other language
drifts, which is the failure that actually happened. One test, either
direction.
"""
@staticmethod
def _dart_const(name: str) -> int:
"""Return the int assigned to `const int <name>` in the Dart model."""
source = DART_MANUAL_WORKOUT.read_text(encoding="utf-8")
match = re.search(rf"const int {name} = (\d+);", source)
assert match is not None, (
f"{name} not found in {DART_MANUAL_WORKOUT} — it was renamed or "
"removed; update this guard so the caps stay pinned."
)
return int(match.group(1))
def test_dart_model_file_exists(self) -> None:
"""A moved/renamed Dart model must fail loudly, not skip the guard."""
assert DART_MANUAL_WORKOUT.is_file(), (
f"{DART_MANUAL_WORKOUT} is missing; the budget caps are no longer "
"pinned across languages."
)
def test_per_7_days_matches_dart_mirror(self) -> None:
"""The 7-day cap must equal kManualWorkoutBudgetPer7Days."""
assert (
self._dart_const("kManualWorkoutBudgetPer7Days")
== MANUAL_WORKOUT_BUDGET_PER_7_DAYS
)
def test_per_30_days_matches_dart_mirror(self) -> None:
"""The 30-day cap must equal kManualWorkoutBudgetPer30Days."""
assert (
self._dart_const("kManualWorkoutBudgetPer30Days")
== MANUAL_WORKOUT_BUDGET_PER_30_DAYS
)

View File

@ -0,0 +1,154 @@
"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py."""
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
# Minimal valid TCX XML for a 40-minute, 6-km run.
_TCX_RUNNING = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
class TestVerifyRunnerupWorkout:
"""Tests for _verify_runnerup_workout (lines 440-447 and 431)."""
def test_returns_clock_tampered_on_skew(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Clock skew detected → clock_tampered without further checks."""
locker = create_locker(mock_tk, tmp_path)
with patch(
"screen_locker._runnerup_verification.check_clock_skew",
return_value=(False, "Clock is off"),
):
status, msg = locker._verify_runnerup_workout()
assert status == "clock_tampered"
assert "Clock" in msg
def test_returns_no_phone_when_device_absent(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No ADB device → no_phone."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._runnerup_verification.check_clock_skew",
return_value=(True, "ok"),
),
patch.object(locker, "_has_adb_device", return_value=False),
):
status, _ = locker._verify_runnerup_workout()
assert status == "no_phone"
def test_returns_file_result_when_exports_exist(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""File-based verification succeeds → result returned (line 431 logged)."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._runnerup_verification.check_clock_skew",
return_value=(True, "ok"),
),
patch.object(locker, "_has_adb_device", return_value=True),
patch.object(
locker,
"_verify_runnerup_via_files",
return_value=("verified", "Running: 6 km in 40 min"),
),
):
status, _msg = locker._verify_runnerup_workout()
assert status == "verified"
def test_falls_back_to_db_when_no_files(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No file exports → DB path tried."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._runnerup_verification.check_clock_skew",
return_value=(True, "ok"),
),
patch.object(locker, "_has_adb_device", return_value=True),
patch.object(locker, "_verify_runnerup_via_files", return_value=None),
patch.object(
locker,
"_verify_runnerup_via_db",
return_value=("not_verified", "no run today"),
) as mock_db,
):
status, _ = locker._verify_runnerup_workout()
assert status == "not_verified"
mock_db.assert_called_once()
# ---------------------------------------------------------------------------
# Branch-coverage gap fixes
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,188 @@
"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py."""
from __future__ import annotations
from typing import TYPE_CHECKING
from screen_locker._constants import (
MIN_WORKOUT_DURATION_MINUTES,
WORKOUT_DURATION_ACCEPT_MINUTES,
)
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
from unittest.mock import MagicMock
# Minimal valid TCX XML for a 40-minute, 6-km run.
_TCX_RUNNING = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
class TestValidateRunnerupDataOrCriteria:
"""Distance and duration are an OR: either one alone qualifies a run."""
def test_long_but_short_distance_is_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""2 km in 45 min → verified on the duration branch alone."""
locker = create_locker(mock_tk, tmp_path)
status, _ = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 2700, "distance_m": 2000}
)
assert status == "verified"
def test_far_but_quick_is_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""3.6 km in 20 min → verified on the distance branch alone.
The old rule imposed a 30-minute floor on every run; it is gone, so a
fast run is no longer rejected for being quick.
"""
locker = create_locker(mock_tk, tmp_path)
status, _ = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 1200, "distance_m": 3600}
)
assert status == "verified"
def test_neither_criterion_met_is_too_short(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""3.0 km in 25 min misses both branches; message names both."""
locker = create_locker(mock_tk, tmp_path)
status, msg = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 1500, "distance_m": 3000}
)
assert status == "too_short"
# Pinned literally: a ':.0f' format spec would render 3.5 as "4".
assert "need 3.5+ km or 40+ min" in msg
def test_distance_boundary_is_inclusive(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Exactly 3.5 km qualifies ("as low as 3.5 km")."""
locker = create_locker(mock_tk, tmp_path)
status, _ = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 600, "distance_m": 3500}
)
assert status == "verified"
def test_duration_boundary_uses_the_hidden_leeway(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Runs answer to the shared accept bar (35), not the advertised 40.
The distance stays at 1 km throughout so the OR distance arm cannot
rescue the run — this pins the duration arm alone.
"""
locker = create_locker(mock_tk, tmp_path)
just_under, _ = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 2094, "distance_m": 1000}
)
assert just_under == "too_short"
at_accept_bar, _ = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 2100, "distance_m": 1000}
)
assert at_accept_bar == "verified"
def test_too_short_message_advertises_40_not_the_real_cutoff(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""The hidden accept bar must never reach the user's eyes."""
locker = create_locker(mock_tk, tmp_path)
_, message = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 600, "distance_m": 1000}
)
assert str(MIN_WORKOUT_DURATION_MINUTES) in message
assert str(WORKOUT_DURATION_ACCEPT_MINUTES) not in message
def test_real_2026_08_14_run_is_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Regression: the real run this rule change was made for.
Pulled from RunnerUp_2026-08-14-00-58-45_Running.tcx — 4 laps summing
to 3817 m / 2502 s. It qualifies on both branches; under the previous
AND rule it was rejected with "Run was 3.8 km — need 5+ km".
"""
locker = create_locker(mock_tk, tmp_path)
status, msg = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 2502, "distance_m": 3817.0}
)
assert status == "verified"
assert "3.8 km" in msg
# ---------------------------------------------------------------------------
# _validate_runnerup_data
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,184 @@
"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py."""
from __future__ import annotations
import datetime as dt
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from screen_locker.tests.conftest import create_locker
from screen_locker.tests.test_runnerup_verification_part1 import _write_tcx
if TYPE_CHECKING:
from pathlib import Path
# Minimal valid TCX XML for a 40-minute, 6-km run.
_TCX_RUNNING = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
class TestBranchCoverageGaps:
"""Targeted tests for uncovered branches in _runnerup_verification.py."""
# ---- 86->82: inner for-loop iterates >1 time in _find_runnerup_exports_for_date
def test_multi_file_listing_loops_inner_for(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Duplicate filename in ls output: second occurrence hits 'already in found'
branch (86->82 — the False branch of 'if remote not in found:')."""
locker = create_locker(mock_tk, tmp_path)
# Same file listed twice → second encounter hits the dedup False-branch
dup_files = (
"RunnerUp_2024-03-15-08-00-00_act.tcx\n"
"RunnerUp_2024-03-15-08-00-00_act.tcx\n"
)
object.__setattr__(
locker,
"_run_adb",
MagicMock(return_value=(True, dup_files)),
)
result = locker._find_runnerup_exports_for_date("2024-03-15")
# Dedup: only one path in the result
assert len(result) >= 1
# ---- 129->131 and 131->126: _parse_tcx with missing TotalTimeSeconds / DistanceMeters
def test_parse_tcx_missing_time_and_distance_elements(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Lap with no TotalTimeSeconds or DistanceMeters: both false-branches hit."""
locker = create_locker(mock_tk, tmp_path)
tcx = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
path = _write_tcx(tmp_path, tcx, "empty_lap.tcx")
result = locker._parse_tcx(path)
# Should still return a dict (0 seconds, 0 m) not None
assert result is not None
assert result["duration_seconds"] == 0
assert result["distance_m"] == 0.0
# ---- 161->154: _verify_runnerup_via_files iterates over multiple exports
def test_verify_via_files_loops_over_multiple_exports(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Two non-verified exports: first sets best (162), second hits the False branch
of 'if best is None:' (161->154), exercising the loop-continue with best set."""
locker = create_locker(mock_tk, tmp_path)
# Both exports return non-verified data (too_short). Duration and
# distance are an OR, so both must fail their minimum -- a fixture
# qualifying on either alone would verify instead of exercising this
# branch.
# Iteration 1: best is None → sets best → loop continues to export 2.
# Iteration 2: best is NOT None → False branch of 'if best is None:' (161->154).
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/a.tcx", "/sdcard/b.tcx"]),
)
short_run = {"sport": 0, "duration_seconds": 60, "distance_m": 1000}
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(return_value=short_run),
)
result = locker._verify_runnerup_via_files()
assert result is not None
status, _ = result
assert status == "too_short"
# ---- 203->209: non-dict log entry in _scan_and_fill_week_runnerup
def test_scan_skips_non_dict_log_entries(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Log entry that is not a dict: isinstance branch False → line 209 reached."""
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
today = dt.date.today()
# Store today's entry as a plain string (not a dict) to trigger branch 203->209
log_file.write_text(
__import__("json").dumps({today.strftime("%Y-%m-%d"): "legacy_value"})
)
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=[]),
)
# Should not raise; no exports → 0 filled
assert locker._scan_and_fill_week_runnerup(log_file) == 0

View File

@ -0,0 +1,159 @@
"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
# Minimal valid TCX XML for a 40-minute, 6-km run.
_TCX_RUNNING = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
class TestVerifyRunnerupViaFiles:
"""Tests for _verify_runnerup_via_files (lines 147-165)."""
def test_returns_none_when_no_exports_found(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No exports for today → None (caller tries DB path)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=[]),
)
assert locker._verify_runnerup_via_files() is None
def test_returns_verified_when_file_passes(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""First valid file → verified immediately."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(
return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000}
),
)
status, _ = locker._verify_runnerup_via_files()
assert status == "verified"
def test_returns_best_when_no_file_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Files found but none verified → returns first non-None validation result.
Both duration and distance are held under their minimums so the OR
in _validate_runnerup_data cannot rescue this fixture on either
branch — otherwise a fixture qualifying on one branch alone would
turn this into a test of that branch, not of the "no file verified"
return-first-result behavior this test is actually about.
"""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(
return_value={"sport": 0, "duration_seconds": 60, "distance_m": 1000}
),
)
result = locker._verify_runnerup_via_files()
assert result is not None
status, _ = result
assert status == "too_short"
def test_returns_fallback_when_all_files_unreadable(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""_pull_and_parse_tcx returns None for every file → fallback not_verified."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(locker, "_pull_and_parse_tcx", MagicMock(return_value=None))
status, _ = locker._verify_runnerup_via_files()
assert status == "not_verified"
# ---------------------------------------------------------------------------
# _scan_and_fill_week_runnerup
# ---------------------------------------------------------------------------

View File

@ -3,16 +3,12 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from screen_locker._constants import (
MIN_WORKOUT_DURATION_MINUTES,
WORKOUT_DURATION_ACCEPT_MINUTES,
)
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
from unittest.mock import MagicMock
# Minimal valid TCX XML for a 40-minute, 6-km run.
_TCX_RUNNING = """\
@ -135,220 +131,3 @@ class TestValidateRunnerupDataDistanceTolerance:
{"sport": 0, "duration_seconds": 1947, "distance_m": 5000}
)
assert status == "verified"
class TestValidateRunnerupDataOrCriteria:
"""Distance and duration are an OR: either one alone qualifies a run."""
def test_long_but_short_distance_is_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""2 km in 45 min → verified on the duration branch alone."""
locker = create_locker(mock_tk, tmp_path)
status, _ = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 2700, "distance_m": 2000}
)
assert status == "verified"
def test_far_but_quick_is_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""3.6 km in 20 min → verified on the distance branch alone.
The old rule imposed a 30-minute floor on every run; it is gone, so a
fast run is no longer rejected for being quick.
"""
locker = create_locker(mock_tk, tmp_path)
status, _ = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 1200, "distance_m": 3600}
)
assert status == "verified"
def test_neither_criterion_met_is_too_short(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""3.0 km in 25 min misses both branches; message names both."""
locker = create_locker(mock_tk, tmp_path)
status, msg = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 1500, "distance_m": 3000}
)
assert status == "too_short"
# Pinned literally: a ':.0f' format spec would render 3.5 as "4".
assert "need 3.5+ km or 40+ min" in msg
def test_distance_boundary_is_inclusive(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Exactly 3.5 km qualifies ("as low as 3.5 km")."""
locker = create_locker(mock_tk, tmp_path)
status, _ = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 600, "distance_m": 3500}
)
assert status == "verified"
def test_duration_boundary_uses_the_hidden_leeway(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Runs answer to the shared accept bar (35), not the advertised 40.
The distance stays at 1 km throughout so the OR distance arm cannot
rescue the run — this pins the duration arm alone.
"""
locker = create_locker(mock_tk, tmp_path)
just_under, _ = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 2094, "distance_m": 1000}
)
assert just_under == "too_short"
at_accept_bar, _ = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 2100, "distance_m": 1000}
)
assert at_accept_bar == "verified"
def test_too_short_message_advertises_40_not_the_real_cutoff(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""The hidden accept bar must never reach the user's eyes."""
locker = create_locker(mock_tk, tmp_path)
_, message = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 600, "distance_m": 1000}
)
assert str(MIN_WORKOUT_DURATION_MINUTES) in message
assert str(WORKOUT_DURATION_ACCEPT_MINUTES) not in message
def test_real_2026_08_14_run_is_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Regression: the real run this rule change was made for.
Pulled from RunnerUp_2026-08-14-00-58-45_Running.tcx — 4 laps summing
to 3817 m / 2502 s. It qualifies on both branches; under the previous
AND rule it was rejected with "Run was 3.8 km — need 5+ km".
"""
locker = create_locker(mock_tk, tmp_path)
status, msg = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 2502, "distance_m": 3817.0}
)
assert status == "verified"
assert "3.8 km" in msg
# ---------------------------------------------------------------------------
# _validate_runnerup_data
# ---------------------------------------------------------------------------
class TestVerifyRunnerupViaFiles:
"""Tests for _verify_runnerup_via_files (lines 147-165)."""
def test_returns_none_when_no_exports_found(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No exports for today → None (caller tries DB path)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=[]),
)
assert locker._verify_runnerup_via_files() is None
def test_returns_verified_when_file_passes(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""First valid file → verified immediately."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(
return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000}
),
)
status, _ = locker._verify_runnerup_via_files()
assert status == "verified"
def test_returns_best_when_no_file_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Files found but none verified → returns first non-None validation result.
Both duration and distance are held under their minimums so the OR
in _validate_runnerup_data cannot rescue this fixture on either
branch — otherwise a fixture qualifying on one branch alone would
turn this into a test of that branch, not of the "no file verified"
return-first-result behavior this test is actually about.
"""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(
return_value={"sport": 0, "duration_seconds": 60, "distance_m": 1000}
),
)
result = locker._verify_runnerup_via_files()
assert result is not None
status, _ = result
assert status == "too_short"
def test_returns_fallback_when_all_files_unreadable(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""_pull_and_parse_tcx returns None for every file → fallback not_verified."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(locker, "_pull_and_parse_tcx", MagicMock(return_value=None))
status, _ = locker._verify_runnerup_via_files()
assert status == "not_verified"
# ---------------------------------------------------------------------------
# _scan_and_fill_week_runnerup
# ---------------------------------------------------------------------------

View File

@ -6,8 +6,9 @@ import os
import shutil
import sqlite3
import tempfile
import time
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from screen_locker.tests.conftest import create_locker
@ -131,8 +132,6 @@ class TestVerifyRunnerupViaDb:
tmp_path: Path,
) -> None:
"""DB with valid run → validated, returns verified."""
import time
db_tmp = tempfile.mkdtemp(prefix="runnerup_test_")
db_path = os.path.join(db_tmp, "runnerup.db")
with sqlite3.connect(db_path) as conn:
@ -159,215 +158,3 @@ class TestVerifyRunnerupViaDb:
# ---------------------------------------------------------------------------
# _verify_runnerup_workout (entry point)
# ---------------------------------------------------------------------------
class TestVerifyRunnerupWorkout:
"""Tests for _verify_runnerup_workout (lines 440-447 and 431)."""
def test_returns_clock_tampered_on_skew(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Clock skew detected → clock_tampered without further checks."""
locker = create_locker(mock_tk, tmp_path)
with patch(
"screen_locker._runnerup_verification.check_clock_skew",
return_value=(False, "Clock is off"),
):
status, msg = locker._verify_runnerup_workout()
assert status == "clock_tampered"
assert "Clock" in msg
def test_returns_no_phone_when_device_absent(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No ADB device → no_phone."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._runnerup_verification.check_clock_skew",
return_value=(True, "ok"),
),
patch.object(locker, "_has_adb_device", return_value=False),
):
status, _ = locker._verify_runnerup_workout()
assert status == "no_phone"
def test_returns_file_result_when_exports_exist(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""File-based verification succeeds → result returned (line 431 logged)."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._runnerup_verification.check_clock_skew",
return_value=(True, "ok"),
),
patch.object(locker, "_has_adb_device", return_value=True),
patch.object(
locker,
"_verify_runnerup_via_files",
return_value=("verified", "Running: 6 km in 40 min"),
),
):
status, _msg = locker._verify_runnerup_workout()
assert status == "verified"
def test_falls_back_to_db_when_no_files(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No file exports → DB path tried."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._runnerup_verification.check_clock_skew",
return_value=(True, "ok"),
),
patch.object(locker, "_has_adb_device", return_value=True),
patch.object(locker, "_verify_runnerup_via_files", return_value=None),
patch.object(
locker,
"_verify_runnerup_via_db",
return_value=("not_verified", "no run today"),
) as mock_db,
):
status, _ = locker._verify_runnerup_workout()
assert status == "not_verified"
mock_db.assert_called_once()
# ---------------------------------------------------------------------------
# Branch-coverage gap fixes
# ---------------------------------------------------------------------------
class TestBranchCoverageGaps:
"""Targeted tests for uncovered branches in _runnerup_verification.py."""
# ---- 86->82: inner for-loop iterates >1 time in _find_runnerup_exports_for_date
def test_multi_file_listing_loops_inner_for(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Duplicate filename in ls output: second occurrence hits 'already in found'
branch (86->82 — the False branch of 'if remote not in found:')."""
locker = create_locker(mock_tk, tmp_path)
# Same file listed twice → second encounter hits the dedup False-branch
dup_files = (
"RunnerUp_2024-03-15-08-00-00_act.tcx\n"
"RunnerUp_2024-03-15-08-00-00_act.tcx\n"
)
object.__setattr__(
locker,
"_run_adb",
MagicMock(return_value=(True, dup_files)),
)
result = locker._find_runnerup_exports_for_date("2024-03-15")
# Dedup: only one path in the result
assert len(result) >= 1
# ---- 129->131 and 131->126: _parse_tcx with missing TotalTimeSeconds / DistanceMeters
def test_parse_tcx_missing_time_and_distance_elements(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Lap with no TotalTimeSeconds or DistanceMeters: both false-branches hit."""
locker = create_locker(mock_tk, tmp_path)
tcx = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
path = _write_tcx(tmp_path, tcx, "empty_lap.tcx")
result = locker._parse_tcx(path)
# Should still return a dict (0 seconds, 0 m) not None
assert result is not None
assert result["duration_seconds"] == 0
assert result["distance_m"] == 0.0
# ---- 161->154: _verify_runnerup_via_files iterates over multiple exports
def test_verify_via_files_loops_over_multiple_exports(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Two non-verified exports: first sets best (162), second hits the False branch
of 'if best is None:' (161->154), exercising the loop-continue with best set."""
locker = create_locker(mock_tk, tmp_path)
# Both exports return non-verified data (too_short). Duration and
# distance are an OR, so both must fail their minimum -- a fixture
# qualifying on either alone would verify instead of exercising this
# branch.
# Iteration 1: best is None → sets best → loop continues to export 2.
# Iteration 2: best is NOT None → False branch of 'if best is None:' (161->154).
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/a.tcx", "/sdcard/b.tcx"]),
)
short_run = {"sport": 0, "duration_seconds": 60, "distance_m": 1000}
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(return_value=short_run),
)
result = locker._verify_runnerup_via_files()
assert result is not None
status, _ = result
assert status == "too_short"
# ---- 203->209: non-dict log entry in _scan_and_fill_week_runnerup
def test_scan_skips_non_dict_log_entries(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Log entry that is not a dict: isinstance branch False → line 209 reached."""
import datetime as dt
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
today = dt.date.today()
# Store today's entry as a plain string (not a dict) to trigger branch 203->209
log_file.write_text(
__import__("json").dumps({today.strftime("%Y-%m-%d"): "legacy_value"})
)
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=[]),
)
# Should not raise; no exports → 0 filled
assert locker._scan_and_fill_week_runnerup(log_file) == 0

View File

@ -2,8 +2,6 @@
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
@ -200,185 +198,3 @@ class TestCheckNonVerifyExitsExtras:
):
locker._check_non_verify_exits()
locker._adjust_shutdown_time_by.assert_not_called()
class TestTryAutoUpgradeSickDayRunnerUp:
"""Tests for RunnerUp paths in _try_auto_upgrade_sick_day (lines 273-286)."""
def test_runnerup_exception_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""OSError from _verify_runnerup_workout → returns False (lines 273-275)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(side_effect=OSError("adb fail")),
)
assert locker._try_auto_upgrade_sick_day() is False
def test_runnerup_verified_saves_entry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RunnerUp returns verified → saves runnerup_verified entry (lines 281-286)."""
log_file = tmp_path / "workout_log.json"
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {}
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("verified", "Running: 6 km in 40 min")),
)
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
)
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None):
result = locker._try_auto_upgrade_sick_day()
assert result is True
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
data = json.loads(log_file.read_text())
assert data[today][0]["workout_data"]["type"] == "runnerup_verified"
assert data[today][0]["workout_data"]["after_sick_day"] == "true"
def test_runnerup_not_verified_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RunnerUp not verified → returns False."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("not_verified", "no run")),
)
assert locker._try_auto_upgrade_sick_day() is False
class TestTryAutoUpgradeEarlyBirdRunnerUp:
"""Tests for RunnerUp paths in screen_lock.py _try_auto_upgrade_early_bird (305-318)."""
def test_runnerup_exception_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RuntimeError from _verify_runnerup_workout → returns False (lines 305-307)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(side_effect=RuntimeError("adb gone")),
)
assert locker._try_auto_upgrade_early_bird() is False
def test_runnerup_verified_saves_entry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RunnerUp returns verified → saves runnerup_verified entry (lines 313-318)."""
log_file = tmp_path / "workout_log.json"
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {}
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("verified", "Running: 6 km in 40 min")),
)
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
)
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None):
result = locker._try_auto_upgrade_early_bird()
assert result is True
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
data = json.loads(log_file.read_text())
assert data[today][0]["workout_data"]["type"] == "runnerup_verified"
assert data[today][0]["workout_data"]["after_early_bird"] == "true"
def test_runnerup_not_verified_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RunnerUp not verified → returns False."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("not_verified", "no run")),
)
assert locker._try_auto_upgrade_early_bird() is False
class TestSyncNow:
"""Tests for sync_now, the public entry point behind ``--sync-only``."""
def test_runs_one_full_sync_pass(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""The timer unit's entry point must actually push and ingest.
``workout-sync.timer`` calls this headlessly; if it stopped delegating,
the timer would run and silently sync nothing.
"""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._sync_mixin.pull_all_manual_records",
return_value=[("manual:x", {})],
),
patch("screen_locker._sync_mixin.push_pc_workouts") as push,
patch(
"screen_locker._sync_mixin.ingest_manual_records",
return_value=["manual:x"],
) as ingest,
):
locker.sync_now()
push.assert_called_once()
ingest.assert_called_once()

View File

@ -2,11 +2,10 @@
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker.tests._unlock_helpers import setup_unlock
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
@ -14,62 +13,7 @@ if TYPE_CHECKING:
class TestUnlockScreenExtras:
"""Tests for unlock_screen extra-workout bonus and streak display (360-389)."""
def _setup_unlock(
self,
mock_tk: MagicMock,
tmp_path: Path,
weekly_count: int = 5,
streak: int = 0,
adjust_ok: bool = True,
seed_today_type: str | None = None,
):
"""Create a locker ready to call unlock_screen.
``seed_today_type`` pre-logs a counted workout for today under a
different ``workout_id``, so the unlock's own verified workout is an
ADDITIONAL same-day one — the case that now earns the +1h bonus.
"""
log_file = tmp_path / "workout_log.json"
if seed_today_type is None:
log_file.write_text("{}")
else:
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
log_file.write_text(
json.dumps(
{
today: [
{
"timestamp": f"{today}T06:00:00+00:00",
"workout_data": {"type": seed_today_type},
"workout_id": f"{seed_today_type}:{today}",
}
]
}
)
)
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {"type": "phone_verified"}
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
object.__setattr__(
locker,
"_adjust_shutdown_time_by",
MagicMock(return_value=adjust_ok),
)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(return_value=(22, 22, 5)),
)
return locker
"""unlock_screen's extra-workout bonus and streak display."""
def test_extra_workout_bonus_shown(
self,
@ -78,7 +22,7 @@ class TestUnlockScreenExtras:
tmp_path: Path,
) -> None:
"""An additional same-day verified workout earns the +1h bonus."""
locker = self._setup_unlock(
locker = setup_unlock(
mock_tk, tmp_path, weekly_count=5, seed_today_type="manual_workout"
)
@ -104,7 +48,7 @@ class TestUnlockScreenExtras:
tmp_path: Path,
) -> None:
"""extra_bonus_delta > 0 → _text called with 'Extra workout'."""
locker = self._setup_unlock(mock_tk, tmp_path, seed_today_type="manual_workout")
locker = setup_unlock(mock_tk, tmp_path, seed_today_type="manual_workout")
# Simulate before=22, after=23 → delta=1
old_cfg = (22, 22, 5)
@ -140,7 +84,7 @@ class TestUnlockScreenExtras:
tmp_path: Path,
) -> None:
"""Additional verified workout but the +1h adjust fails → delta stays 0."""
locker = self._setup_unlock(
locker = setup_unlock(
mock_tk,
tmp_path,
seed_today_type="manual_workout",
@ -169,7 +113,7 @@ class TestUnlockScreenExtras:
tmp_path: Path,
) -> None:
"""Re-reading the shutdown config fails after the +1h → delta stays 0."""
locker = self._setup_unlock(mock_tk, tmp_path, seed_today_type="manual_workout")
locker = setup_unlock(mock_tk, tmp_path, seed_today_type="manual_workout")
# old_cfg readable, new_cfg unreadable → no delta can be computed.
locker._read_shutdown_config.side_effect = [(22, 22, 5), None]
@ -188,80 +132,6 @@ class TestUnlockScreenExtras:
assert not any("Extra workout" in c for c in text_calls)
def test_streak_displayed_when_nonzero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""streak >= 1 → _text shows streak line (line 389)."""
locker = self._setup_unlock(mock_tk, tmp_path, weekly_count=3, adjust_ok=False)
text_calls: list[str] = []
def _capture_text(msg: str, **kw: object) -> None:
text_calls.append(msg)
object.__setattr__(locker, "_text", _capture_text)
with (
patch(
"screen_locker._workout_credit.count_weekly_workouts",
return_value=3,
),
patch(
"screen_locker._unlock_view.current_streak",
return_value=3,
),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
assert any("streak" in c.lower() for c in text_calls)
def test_extra_bonus_skipped_when_old_cfg_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""old_cfg is None → branch 361->366: bonus block skipped, delta stays 0."""
locker = self._setup_unlock(mock_tk, tmp_path)
# _read_shutdown_config returns None → condition at 361 is False
locker._read_shutdown_config.return_value = None
with (
patch(
"screen_locker._workout_credit.count_weekly_workouts",
return_value=5,
),
patch("screen_locker._unlock_view.current_streak", return_value=0),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
# No assertion beyond "no crash" — we just needed the branch executed.
def test_extra_bonus_skipped_when_new_cfg_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""new_cfg is None → branch 363->366: delta stays 0 even after adjust."""
locker = self._setup_unlock(mock_tk, tmp_path)
# First call (old_cfg): valid; second call (new_cfg after adjust): None
locker._read_shutdown_config.side_effect = [(22, 22, 5), None]
with (
patch(
"screen_locker._workout_credit.count_weekly_workouts",
return_value=5,
),
patch("screen_locker._unlock_view.current_streak", return_value=0),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
class TestIngestSyncedManualWorkouts:
"""Tests for _ingest_synced_manual_workouts (manual-sync wiring)."""
@ -269,6 +139,7 @@ class TestIngestSyncedManualWorkouts:
def test_logs_each_ingested_record(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Logs each ingested record."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
@ -286,6 +157,7 @@ class TestIngestSyncedManualWorkouts:
def test_no_records_ingests_nothing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""No records ingests nothing."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(

View File

@ -0,0 +1,195 @@
"""Tests targeting remaining screen_lock.py coverage gaps."""
from __future__ import annotations
from datetime import datetime, timezone
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 TestTryAutoUpgradeSickDayRunnerUp:
"""Tests for RunnerUp paths in _try_auto_upgrade_sick_day (lines 273-286)."""
def test_runnerup_exception_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""OSError from _verify_runnerup_workout → returns False (lines 273-275)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(side_effect=OSError("adb fail")),
)
assert locker._try_auto_upgrade_sick_day() is False
def test_runnerup_verified_saves_entry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RunnerUp returns verified → saves runnerup_verified entry (lines 281-286)."""
log_file = tmp_path / "workout_log.json"
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {}
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("verified", "Running: 6 km in 40 min")),
)
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
)
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None):
result = locker._try_auto_upgrade_sick_day()
assert result is True
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
data = json.loads(log_file.read_text())
assert data[today][0]["workout_data"]["type"] == "runnerup_verified"
assert data[today][0]["workout_data"]["after_sick_day"] == "true"
def test_runnerup_not_verified_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RunnerUp not verified → returns False."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("not_verified", "no run")),
)
assert locker._try_auto_upgrade_sick_day() is False
class TestTryAutoUpgradeEarlyBirdRunnerUp:
"""Tests for RunnerUp paths in screen_lock.py _try_auto_upgrade_early_bird (305-318)."""
def test_runnerup_exception_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RuntimeError from _verify_runnerup_workout → returns False (lines 305-307)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(side_effect=RuntimeError("adb gone")),
)
assert locker._try_auto_upgrade_early_bird() is False
def test_runnerup_verified_saves_entry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RunnerUp returns verified → saves runnerup_verified entry (lines 313-318)."""
log_file = tmp_path / "workout_log.json"
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {}
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("verified", "Running: 6 km in 40 min")),
)
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
)
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None):
result = locker._try_auto_upgrade_early_bird()
assert result is True
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
data = json.loads(log_file.read_text())
assert data[today][0]["workout_data"]["type"] == "runnerup_verified"
assert data[today][0]["workout_data"]["after_early_bird"] == "true"
def test_runnerup_not_verified_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RunnerUp not verified → returns False."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("not_verified", "no run")),
)
assert locker._try_auto_upgrade_early_bird() is False
class TestSyncNow:
"""Tests for sync_now, the public entry point behind ``--sync-only``."""
def test_runs_one_full_sync_pass(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""The timer unit's entry point must actually push and ingest.
``workout-sync.timer`` calls this headlessly; if it stopped delegating,
the timer would run and silently sync nothing.
"""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._sync_mixin.pull_all_manual_records",
return_value=[("manual:x", {})],
),
patch("screen_locker._sync_mixin.push_pc_workouts") as push,
patch(
"screen_locker._sync_mixin.ingest_manual_records",
return_value=["manual:x"],
) as ingest,
):
locker.sync_now()
push.assert_called_once()
ingest.assert_called_once()

View File

@ -0,0 +1,93 @@
"""Streak and edge-case reward rendering for unlock_screen.
Split out of test_screen_lock_coverage_part2.py for the 250-line cap; both
halves share the setup_unlock factory in _unlock_helpers.py.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker.tests._unlock_helpers import setup_unlock
if TYPE_CHECKING:
from pathlib import Path
class TestUnlockScreenRewards:
"""Streak display and the bonus paths that decline to award."""
def test_streak_displayed_when_nonzero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""streak >= 1 → _text shows streak line (line 389)."""
locker = setup_unlock(mock_tk, tmp_path, weekly_count=3, adjust_ok=False)
text_calls: list[str] = []
def _capture_text(msg: str, **kw: object) -> None:
text_calls.append(msg)
object.__setattr__(locker, "_text", _capture_text)
with (
patch(
"screen_locker._workout_credit.count_weekly_workouts",
return_value=3,
),
patch(
"screen_locker._unlock_view.current_streak",
return_value=3,
),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
assert any("streak" in c.lower() for c in text_calls)
def test_extra_bonus_skipped_when_old_cfg_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""old_cfg is None → branch 361->366: bonus block skipped, delta stays 0."""
locker = setup_unlock(mock_tk, tmp_path)
# _read_shutdown_config returns None → condition at 361 is False
locker._read_shutdown_config.return_value = None
with (
patch(
"screen_locker._workout_credit.count_weekly_workouts",
return_value=5,
),
patch("screen_locker._unlock_view.current_streak", return_value=0),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
# No assertion beyond "no crash" — we just needed the branch executed.
def test_extra_bonus_skipped_when_new_cfg_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""new_cfg is None → branch 363->366: delta stays 0 even after adjust."""
locker = setup_unlock(mock_tk, tmp_path)
# First call (old_cfg): valid; second call (new_cfg after adjust): None
locker._read_shutdown_config.side_effect = [(22, 22, 5), None]
with (
patch(
"screen_locker._workout_credit.count_weekly_workouts",
return_value=5,
),
patch("screen_locker._unlock_view.current_streak", return_value=0),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()

View File

@ -8,16 +8,13 @@ from unittest.mock import MagicMock, patch
from screen_locker._status_view_verify import PhoneCheckOutcome
from screen_locker._workout_credit import WorkoutCreditResult
from screen_locker.status_view import (
_compliance_state_word,
_make_bare_verifier,
)
from screen_locker.tests._status_view_helpers import (
_button_texts,
_lock_explanation,
_make_window,
_snapshot,
_texts,
_week,
)
if TYPE_CHECKING:
@ -227,28 +224,3 @@ class TestMakeBareVerifier:
verifier = _make_bare_verifier(log_file)
assert verifier.log_file == log_file
assert verifier.workout_data == {}
class TestComplianceStateWord:
"""ComplianceStateWord."""
def test_lock_when_fired(self) -> None:
"""Lock when fired."""
snap = _snapshot(lock_explanation=_lock_explanation(fired=True))
assert _compliance_state_word(snap) == "lock"
def test_warn_when_not_fired_but_remaining(self) -> None:
"""Warn when not fired but remaining."""
snap = _snapshot(
lock_explanation=_lock_explanation(fired=False),
week=_week(remaining=2),
)
assert _compliance_state_word(snap) == "warn"
def test_ok_when_not_fired_and_minimum_met(self) -> None:
"""Ok when not fired and minimum met."""
snap = _snapshot(
lock_explanation=_lock_explanation(fired=False),
week=_week(remaining=0),
)
assert _compliance_state_word(snap) == "ok"

View File

@ -0,0 +1,37 @@
"""Tests for status_view.py: phone-check flow, buttons, main() CLI entry point."""
from __future__ import annotations
from screen_locker.status_view import (
_compliance_state_word,
)
from screen_locker.tests._status_view_helpers import (
_lock_explanation,
_snapshot,
_week,
)
class TestComplianceStateWord:
"""ComplianceStateWord."""
def test_lock_when_fired(self) -> None:
"""Lock when fired."""
snap = _snapshot(lock_explanation=_lock_explanation(fired=True))
assert _compliance_state_word(snap) == "lock"
def test_warn_when_not_fired_but_remaining(self) -> None:
"""Warn when not fired but remaining."""
snap = _snapshot(
lock_explanation=_lock_explanation(fired=False),
week=_week(remaining=2),
)
assert _compliance_state_word(snap) == "warn"
def test_ok_when_not_fired_and_minimum_met(self) -> None:
"""Ok when not fired and minimum met."""
snap = _snapshot(
lock_explanation=_lock_explanation(fired=False),
week=_week(remaining=0),
)
assert _compliance_state_word(snap) == "ok"