mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 19:13:55 +01:00
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
MIN_RUN_DURATION_MINUTES and MIN_RUN_DISTANCE_KM were an AND (30+ min AND 5.0+ km), which rejected a fast 5 km run that took under 30 minutes. They are now an OR at 40 min / 3.5 km: a run qualifies at either threshold alone. Distance keeps its 5% GPS tolerance; duration, from the device clock, does not. Fixed one stale test whose fixture (1 min / 6 km) predated the OR change: 6 km alone now qualifies under the new rule, so the fixture needed to fail BOTH branches to still exercise the "no file verified, return first result" path it was written for. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
388 lines
13 KiB
Python
388 lines
13 KiB
Python
"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock
|
|
|
|
from screen_locker.tests.conftest import create_locker
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
# Minimal valid TCX XML for a 40-minute, 6-km run.
|
|
_TCX_RUNNING = """\
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<TrainingCenterDatabase
|
|
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
|
|
<Activities>
|
|
<Activity Sport="Running">
|
|
<Lap>
|
|
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
|
|
<DistanceMeters>6000.0</DistanceMeters>
|
|
</Lap>
|
|
</Activity>
|
|
</Activities>
|
|
</TrainingCenterDatabase>
|
|
"""
|
|
|
|
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
|
|
_TCX_GYM = """\
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<TrainingCenterDatabase
|
|
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
|
|
<Activities>
|
|
<Activity Sport="Gym">
|
|
<Lap>
|
|
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
|
|
<DistanceMeters>0.0</DistanceMeters>
|
|
</Lap>
|
|
</Activity>
|
|
</Activities>
|
|
</TrainingCenterDatabase>
|
|
"""
|
|
|
|
# Two laps that together make a valid run.
|
|
_TCX_MULTI_LAP = """\
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<TrainingCenterDatabase
|
|
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
|
|
<Activities>
|
|
<Activity Sport="Running">
|
|
<Lap>
|
|
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
|
|
<DistanceMeters>3000.0</DistanceMeters>
|
|
</Lap>
|
|
<Lap>
|
|
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
|
|
<DistanceMeters>3000.0</DistanceMeters>
|
|
</Lap>
|
|
</Activity>
|
|
</Activities>
|
|
</TrainingCenterDatabase>
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str:
|
|
"""Write TCX content to a temp file and return the path string."""
|
|
p = tmp_path / name
|
|
p.write_text(content)
|
|
return str(p)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _validate_runnerup_data
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateRunnerupData:
|
|
"""Tests for _validate_runnerup_data (lines 388-411)."""
|
|
|
|
def test_wrong_sport_returns_wrong_sport_status(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Sport not in RUNNERUP_ACCEPTED_SPORTS → wrong_sport."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
# Sport 6 = Gym, not accepted
|
|
status, msg = locker._validate_runnerup_data(
|
|
{"sport": 6, "duration_seconds": 3600, "distance_m": 6000}
|
|
)
|
|
assert status == "wrong_sport"
|
|
assert "Gym" in msg
|
|
|
|
def test_unknown_sport_number_shown_in_message(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Unknown sport integer falls back to 'unknown(N)' label."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
status, msg = locker._validate_runnerup_data(
|
|
{"sport": 99, "duration_seconds": 3600, "distance_m": 6000}
|
|
)
|
|
assert status == "wrong_sport"
|
|
assert "unknown(99)" in msg
|
|
|
|
def test_short_duration_alone_still_verified_on_distance(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Short duration alone is not enough to reject — distance still qualifies.
|
|
|
|
The criteria are an OR, so 6 km in 1 min is verified on distance. If
|
|
the OR ever regressed to an AND this would return too_short.
|
|
"""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
status, msg = locker._validate_runnerup_data(
|
|
{"sport": 0, "duration_seconds": 60, "distance_m": 6000}
|
|
)
|
|
assert status == "verified"
|
|
assert "6.0 km" in msg
|
|
|
|
def test_too_short_distance_returns_too_short(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Both criteria missed → too_short naming both.
|
|
|
|
0.1 km is far under the distance line and 30 min is under the duration
|
|
line, so neither branch of the OR qualifies.
|
|
"""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
status, msg = locker._validate_runnerup_data(
|
|
{"sport": 0, "duration_seconds": 1800, "distance_m": 100}
|
|
)
|
|
assert status == "too_short"
|
|
assert "3.5+ km" in msg
|
|
assert "40+ min" in msg
|
|
|
|
def test_valid_run_returns_verified(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Sufficient sport, duration, distance → verified with sport name in message."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
status, msg = locker._validate_runnerup_data(
|
|
{"sport": 0, "duration_seconds": 2400, "distance_m": 6000}
|
|
)
|
|
assert status == "verified"
|
|
assert "Running" in msg
|
|
|
|
def test_orienteering_accepted(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Sport 3 (Orienteering) is in RUNNERUP_ACCEPTED_SPORTS."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
status, _ = locker._validate_runnerup_data(
|
|
{"sport": 3, "duration_seconds": 2400, "distance_m": 6000}
|
|
)
|
|
assert status == "verified"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_tcx
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseTcx:
|
|
"""Tests for _parse_tcx (lines 109-134)."""
|
|
|
|
def test_parses_valid_running_tcx(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Valid Running TCX returns correct sport/duration/distance dict."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
path = _write_tcx(tmp_path, _TCX_RUNNING)
|
|
result = locker._parse_tcx(path)
|
|
assert result is not None
|
|
assert result["sport"] == 0 # Running
|
|
assert result["duration_seconds"] == 2400
|
|
assert result["distance_m"] == 6000.0
|
|
|
|
def test_parse_error_returns_none(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Malformed XML is caught by ParseError; returns None."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
path = _write_tcx(tmp_path, "<not-valid xml << ")
|
|
assert locker._parse_tcx(path) is None
|
|
|
|
def test_missing_activity_element_returns_none(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""TCX with no <Activity> element returns None."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
tcx = """\
|
|
<?xml version="1.0"?>
|
|
<TrainingCenterDatabase
|
|
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
|
|
</TrainingCenterDatabase>
|
|
"""
|
|
path = _write_tcx(tmp_path, tcx)
|
|
assert locker._parse_tcx(path) is None
|
|
|
|
def test_multi_lap_sums_correctly(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Two laps: totals are summed across both."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
path = _write_tcx(tmp_path, _TCX_MULTI_LAP)
|
|
result = locker._parse_tcx(path)
|
|
assert result is not None
|
|
assert result["duration_seconds"] == 2400
|
|
assert result["distance_m"] == 6000.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _find_runnerup_exports_for_date
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFindRunnerupExportsForDate:
|
|
"""Tests for _find_runnerup_exports_for_date (lines 77-88)."""
|
|
|
|
def test_returns_empty_when_adb_fails(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""_run_adb returning False → empty list."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
object.__setattr__(locker, "_run_adb", MagicMock(return_value=(False, "")))
|
|
assert locker._find_runnerup_exports_for_date("2024-03-15") == []
|
|
|
|
def test_returns_empty_when_no_matching_files(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""ADB listing with no date-matching .tcx files → empty list."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
object.__setattr__(
|
|
locker,
|
|
"_run_adb",
|
|
MagicMock(return_value=(True, "RunnerUp_2024-01-01-10-00-00.tcx\n")),
|
|
)
|
|
assert locker._find_runnerup_exports_for_date("2024-03-15") == []
|
|
|
|
def test_returns_matching_tcx_files(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Matching filename with date string → path included in result."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
object.__setattr__(
|
|
locker,
|
|
"_run_adb",
|
|
MagicMock(return_value=(True, "RunnerUp_2024-03-15-10-30-00_act.tcx\n")),
|
|
)
|
|
result = locker._find_runnerup_exports_for_date("2024-03-15")
|
|
assert len(result) >= 1
|
|
assert "2024-03-15" in result[0]
|
|
assert result[0].endswith(".tcx")
|
|
|
|
def test_deduplicates_across_dirs(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Same remote path appearing in two dirs is not duplicated."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
# Both export dirs return the same filename (different dirs → different paths)
|
|
object.__setattr__(
|
|
locker,
|
|
"_run_adb",
|
|
MagicMock(return_value=(True, "RunnerUp_2024-03-15-10-30-00_act.tcx\n")),
|
|
)
|
|
|
|
result = locker._find_runnerup_exports_for_date("2024-03-15")
|
|
# Paths come from different dirs so both are included, but no duplicates
|
|
assert len(result) == len(set(result))
|
|
|
|
def test_skips_empty_listing(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Empty stdout from ADB is skipped without adding entries."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
object.__setattr__(locker, "_run_adb", MagicMock(return_value=(True, " \n")))
|
|
assert locker._find_runnerup_exports_for_date("2024-03-15") == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _pull_and_parse_tcx
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPullAndParseTcx:
|
|
"""Tests for _pull_and_parse_tcx (lines 92-101)."""
|
|
|
|
def test_returns_none_when_pull_fails(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Failed adb pull → None returned."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
object.__setattr__(locker, "_run_adb", MagicMock(return_value=(False, "")))
|
|
assert locker._pull_and_parse_tcx("/sdcard/some.tcx") is None
|
|
|
|
def test_returns_none_when_file_not_written(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""adb pull succeeds but local file absent (race) → None."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
# _run_adb returns True but does not actually write the file.
|
|
object.__setattr__(locker, "_run_adb", MagicMock(return_value=(True, "")))
|
|
assert locker._pull_and_parse_tcx("/sdcard/some.tcx") is None
|
|
|
|
def test_returns_parsed_data_on_success(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
mock_sys_exit: MagicMock,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Successful pull + valid TCX → parsed activity dict."""
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
tcx_src = tmp_path / "source.tcx"
|
|
tcx_src.write_text(_TCX_RUNNING)
|
|
|
|
def _fake_pull(args: list[str]) -> tuple[bool, str]:
|
|
if args[0] == "pull":
|
|
shutil.copy(str(tcx_src), args[2])
|
|
return True, ""
|
|
return True, ""
|
|
|
|
object.__setattr__(locker, "_run_adb", MagicMock(side_effect=_fake_pull))
|
|
result = locker._pull_and_parse_tcx("/sdcard/activity.tcx")
|
|
assert result is not None
|
|
assert result["sport"] == 0
|
|
assert result["duration_seconds"] == 2400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _verify_runnerup_via_files
|
|
# ---------------------------------------------------------------------------
|