screen-locker/screen_locker/tests/test_run_tcx_golden.py
Krzysztof kuhy Rudnicki 71cb5eecdb
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Drop GPS dropout bridging
Revert 6777656; the app again writes zero distance across a gap
over 30 s. Also drop the importer-test skip for its fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYwyP67ppvkeie3X1F
2026-11-11 12:12:42 +01:00

168 lines
6.5 KiB
Python

"""Cross-language golden test: the app's TCX is accepted by the real verifier.
The workout app (Dart, ``lib/services/tcx_codec.dart``) writes the TCX files
the PC credits. Each language's own round trip passes happily while the other
side's parser disagrees, so the Dart twin
(``stronglift_replacement/workout_app/test/services/tcx_golden_fixture_test.dart``)
encodes ``fixtures/run_tcx/golden_tracks.json`` and compares the result byte
for byte with the ``.tcx`` files checked in next to it. This test feeds those
SAME files to the real ``RunnerUpVerificationMixin`` -- date glob, parser,
run validation and walk summing -- and asserts the km / minutes the JSON
promises. Everything runs on a temporary drop directory: nothing here reads
or writes ``~/data/cloud/RunnerUp``.
"""
from __future__ import annotations
import json
from pathlib import Path
import shutil
from unittest.mock import MagicMock, patch
import pytest
from screen_locker._runnerup_verification import RunnerUpVerificationMixin
FIXTURES = Path(__file__).parent / "fixtures" / "run_tcx"
SAMPLE = "RunnerUp_2026-08-12-17-42-11_Running.tcx"
# ``_TCX_SPORT_TO_INT`` code for "Running" in screen_locker/_runnerup_tcx.py.
SPORT_RUNNING = 0
def _golden() -> dict[str, dict[str, object]]:
return json.loads((FIXTURES / "golden_tracks.json").read_text(encoding="utf-8"))
class Verifier(RunnerUpVerificationMixin):
"""The real mixin with no phone: only the WebDAV drop directory is read."""
def __init__(self) -> None:
self.adb = MagicMock(return_value=(False, ""))
def _has_adb_device(self) -> bool:
return False
def _run_adb(self, args: list[str]) -> tuple[bool, str]:
return self.adb(args)
@pytest.fixture(name="inbox")
def inbox_fixture(tmp_path: Path) -> Path:
"""A temporary WebDAV inbox standing in for ``~/data/cloud/RunnerUp``."""
inbox = tmp_path / "cloud" / "RunnerUp"
inbox.mkdir(parents=True)
return inbox
def _verify(inbox: Path, today: str) -> tuple[str, str] | None:
"""Run the real file-based verification on ``inbox`` as if it were ``today``."""
with (
patch("screen_locker._runnerup_verification.RUNNERUP_WEBDAV_DIRS", (inbox,)),
patch("screen_locker._runnerup_verification.today_str", return_value=today),
):
return Verifier()._verify_runnerup_via_files()
def _drop(inbox: Path, name: str) -> None:
shutil.copyfile(FIXTURES / name, inbox / name)
class TestGoldenRun:
"""The app's run file passes the PC's run validation unchanged."""
def test_run_is_verified_with_the_promised_km_and_minutes(
self, inbox: Path
) -> None:
"""Date glob, parser and validation accept the app's run file."""
spec = _golden()["running"]
_drop(inbox, str(spec["file"]))
expect = spec["expect"]
assert isinstance(expect, dict)
result = _verify(inbox, "2026-11-09")
assert result == (
expect["status"],
f"Running: {expect['distance_km']} km in {expect['minutes']} min",
)
def test_parsed_fields_match_the_json(self, inbox: Path) -> None:
"""The parser reads sport, duration and distance as the app wrote them."""
spec = _golden()["running"]
_drop(inbox, str(spec["file"]))
data = Verifier()._pull_and_parse_tcx(str(inbox / str(spec["file"])))
assert data is not None
assert data["sport"] == SPORT_RUNNING
assert data["walking"] is False
assert data["duration_seconds"] == spec["total_seconds"]
assert data["distance_m"] == pytest.approx(float(str(spec["distance_m"])))
assert data["activity_id"] == str(spec["started_at"])
def test_the_local_date_in_the_name_is_what_is_globbed(self, inbox: Path) -> None:
"""Another day does not see the file: the stamp is local, the glob is too."""
spec = _golden()["running"]
_drop(inbox, str(spec["file"]))
assert _verify(inbox, "2026-11-10") is None
class TestGoldenWalk:
"""The app's walk file is a walk, and its moving time is what the PC sums."""
def test_walk_is_verified_with_the_promised_moving_minutes(
self, inbox: Path
) -> None:
"""A walk is summed per day: legs, km and moving minutes as promised."""
spec = _golden()["walking"]
_drop(inbox, str(spec["file"]))
expect = spec["expect"]
assert isinstance(expect, dict)
result = _verify(inbox, "2026-11-09")
assert result == (
expect["status"],
(
f"Walking: {expect['legs']} walk(s), {expect['distance_km']} km, "
f"{expect['moving_minutes']} min moving"
),
)
def test_pc_moving_time_equals_the_apps_total_seconds(self, inbox: Path) -> None:
"""Dart's moving-time rule and the PC's ``track_summary`` agree exactly."""
spec = _golden()["walking"]
_drop(inbox, str(spec["file"]))
data = Verifier()._pull_and_parse_tcx(str(inbox / str(spec["file"])))
assert data is not None
assert data["walking"] is True
assert data["moving_seconds"] == pytest.approx(
float(str(spec["total_seconds"]))
)
assert data["activity_id"] == str(spec["started_at"])
def test_walk_is_marked_by_file_name_as_with_runnerup(self, inbox: Path) -> None:
"""Like RunnerUp, the app writes ``Sport="Other"``; the name says walk."""
spec = _golden()["walking"]
name = str(spec["file"])
body = (FIXTURES / name).read_text(encoding="utf-8")
assert 'Sport="Other"' in body
renamed = inbox / "activity.tcx"
renamed.write_text(body)
data = Verifier()._pull_and_parse_tcx(str(renamed))
assert data is not None
assert data["walking"] is False
(inbox / name).write_text(body)
by_name = Verifier()._pull_and_parse_tcx(str(inbox / name))
assert by_name is not None
assert by_name["walking"] is True
class TestRunnerUpSample:
"""The trimmed real RunnerUp export parses to the numbers Dart's decoder sees."""
def test_parser_sums_both_laps(self, inbox: Path) -> None:
"""Same literals as the Dart decoder test: 624 + 626 s, two laps' metres."""
_drop(inbox, SAMPLE)
data = Verifier()._pull_and_parse_tcx(str(inbox / SAMPLE))
assert data is not None
assert data["sport"] == SPORT_RUNNING
assert data["walking"] is False
assert data["duration_seconds"] == 1250
assert data["distance_m"] == pytest.approx(983.6306 + 980.7304, abs=1e-3)
assert data["activity_id"] == "2026-08-12T15:42:11Z"