screen-locker/screen_locker/tests/test_runnerup_verification_part1.py
Krzysztof kuhy Rudnicki a2ce5049f0
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
Qualify a run on EITHER duration or distance, not both
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
2026-08-14 11:04:18 +02:00

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
# ---------------------------------------------------------------------------