mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-14 23:10:50 +01:00
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
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:
parent
dd8296fd3f
commit
3476dd3afd
@ -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
|
||||
|
||||
73
screen_locker/tests/_unlock_helpers.py
Normal file
73
screen_locker/tests/_unlock_helpers.py
Normal 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
|
||||
@ -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
|
||||
)
|
||||
|
||||
258
screen_locker/tests/test_manual_workout_features_part2.py
Normal file
258
screen_locker/tests/test_manual_workout_features_part2.py
Normal 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()
|
||||
74
screen_locker/tests/test_manual_workout_features_part3.py
Normal file
74
screen_locker/tests/test_manual_workout_features_part3.py
Normal 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
|
||||
)
|
||||
154
screen_locker/tests/test_runnerup_verification_part11.py
Normal file
154
screen_locker/tests/test_runnerup_verification_part11.py
Normal 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
|
||||
# ---------------------------------------------------------------------------
|
||||
188
screen_locker/tests/test_runnerup_verification_part12.py
Normal file
188
screen_locker/tests/test_runnerup_verification_part12.py
Normal 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
|
||||
# ---------------------------------------------------------------------------
|
||||
184
screen_locker/tests/test_runnerup_verification_part13.py
Normal file
184
screen_locker/tests/test_runnerup_verification_part13.py
Normal 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
|
||||
159
screen_locker/tests/test_runnerup_verification_part14.py
Normal file
159
screen_locker/tests/test_runnerup_verification_part14.py
Normal 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
|
||||
195
screen_locker/tests/test_screen_lock_coverage_part3.py
Normal file
195
screen_locker/tests/test_screen_lock_coverage_part3.py
Normal 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()
|
||||
93
screen_locker/tests/test_screen_lock_coverage_part4.py
Normal file
93
screen_locker/tests/test_screen_lock_coverage_part4.py
Normal 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()
|
||||
@ -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"
|
||||
|
||||
37
screen_locker/tests/test_status_view_part7.py
Normal file
37
screen_locker/tests/test_status_view_part7.py
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user