screen-locker/screen_locker/tests/test_runnerup_verification_part1.py
Krzysztof kuhy Rudnicki 399f9815c4
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
Add phone-workout GitHub sync via shared crdt_sync (Workstream C)
Sync the workout log across devices using the shared crdt-sync /
crdt_sync_dart CRDT transport (same scheme as diet_guard).

- Python: new _workout_sync.py + _temperature_status_mixin.py; wire sync
  into screen_lock.py / status_view.py; add the crdt-sync git dependency
  and test_sync_fixtures / test_workout_sync suites.
- Dart (workout_app): new workout_sync_service.dart, github_device_auth.dart,
  sync_settings.dart with tests + fakes; settings/workout screens updated.
- docs: workstream-d-shared-crdt-transport.md.

Verification: pytest 747 passed at 100% coverage; flutter analyze clean;
flutter test passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
2026-07-11 00:06:59 +02:00

379 lines
12 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_too_short_duration_returns_too_short(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Duration below MIN_RUN_DURATION_MINUTES → too_short with 'min' in message."""
locker = create_locker(mock_tk, tmp_path)
status, msg = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 60, "distance_m": 6000}
)
assert status == "too_short"
assert "min" in msg
def test_too_short_distance_returns_too_short(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Distance below MIN_RUN_DISTANCE_KM → too_short with 'km' in message."""
locker = create_locker(mock_tk, tmp_path)
status, msg = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 2400, "distance_m": 100}
)
assert status == "too_short"
assert "km" 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
# ---------------------------------------------------------------------------