mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 23:07:03 +01:00
RunnerUp can now upload to Endurain directly as well as through the WebDAV inbox, and the two paths cannot see each other: the same run landed twice (activities 14 and 15, identical start time, distances differing only by tcx/gpx rounding). The ledger only knows what this importer handled, so it could not catch it. Add a third dedupe check that asks Endurain what it already holds, queried once per run. Endurain exposes no query-by-start-time endpoint, so a page of recent activities is fetched and compared client-side, with a +/-120s tolerance: the file for activity 15 embeds 21:51:04Z while the server stored 21:51:05Z, so exact equality misses the very duplicate that motivated this. A failed or unauthorised query returns None, never an empty list, so it can never be read as "nothing matches" and upload on the strength of it. Export filenames are parsed as LOCAL wall-clock time. RunnerUp_2026-08-14- 00-58-45_Running is 2026-08-13 22:58:45 UTC; reading it as UTC would move a late-evening run onto the wrong day and defeat the comparison. Also fix the adb fallback, which broke the moment a second device attached: phone_attached() returned true if ANY device was in the 'device' state, then every bare adb call failed with "more than one device/emulator" as a warning on a path nobody watches. The serial is now resolved once and threaded into every call as -s. Ambiguity is settled by configuration, never by guessing -- several devices and no ENDURAIN_ADB_SERIAL means the fallback is skipped. The remote check stays inert until the API key is granted activities:read; until then it falls back to the ledger, which is the previous behaviour. 85 tests, 100% branch coverage on all four modules, no suppressions. The oversized test modules are split by concern to stay under the 250-line cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbYEnfGnf1988cdFdGebot
135 lines
4.2 KiB
Python
135 lines
4.2 KiB
Python
"""Ledger behaviour, especially the two independent dedupe paths."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from python_pkg.endurain_import import ledger
|
|
from python_pkg.endurain_import.ledger import (
|
|
Entry,
|
|
Ledger,
|
|
LedgerCorruptError,
|
|
activity_key,
|
|
file_digest,
|
|
now_iso,
|
|
)
|
|
|
|
|
|
def _entry(sha: str = "abc", key: str = "RunnerUp_ts_Running") -> Entry:
|
|
return Entry(sha, key, "f.tcx", 7, now_iso())
|
|
|
|
|
|
def test_digest_is_content_addressed(tmp_path: Path) -> None:
|
|
a = tmp_path / "a.tcx"
|
|
b = tmp_path / "b.tcx"
|
|
a.write_text("same")
|
|
b.write_text("same")
|
|
assert file_digest(a) == file_digest(b)
|
|
|
|
b.write_text("different")
|
|
assert file_digest(a) != file_digest(b)
|
|
|
|
|
|
def test_activity_key_collapses_export_formats() -> None:
|
|
"""The .gpx and .tcx of one run must share a key."""
|
|
tcx = Path("RunnerUp_2026-08-22-23-51-04_Running.tcx")
|
|
gpx = Path("RunnerUp_2026-08-22-23-51-04_Running.gpx")
|
|
assert activity_key(tcx) == activity_key(gpx)
|
|
|
|
|
|
def test_activity_key_ignores_device_model() -> None:
|
|
"""WebDAV names embed Build.MODEL; adb names do not."""
|
|
webdav = Path("RunnerUp_Pixel_6a_2026-08-22-23-51-04_Running.tcx")
|
|
adb = Path("RunnerUp_2026-08-22-23-51-04_Running.tcx")
|
|
assert activity_key(webdav) == activity_key(adb)
|
|
|
|
|
|
def test_activity_key_separates_distinct_runs() -> None:
|
|
first = Path("RunnerUp_2026-08-22-23-51-04_Running.tcx")
|
|
second = Path("RunnerUp_2026-08-19-19-10-04_Running.tcx")
|
|
assert activity_key(first) != activity_key(second)
|
|
|
|
|
|
def test_activity_key_falls_back_to_stem() -> None:
|
|
assert activity_key(Path("whatever.tcx")) == "whatever"
|
|
|
|
|
|
def test_record_and_membership(tmp_path: Path) -> None:
|
|
ledger = Ledger(tmp_path / "l.json")
|
|
assert len(ledger) == 0
|
|
ledger.record(_entry())
|
|
assert "abc" in ledger
|
|
assert ledger.has_activity("RunnerUp_ts_Running")
|
|
assert not ledger.has_activity("other")
|
|
assert len(ledger) == 1
|
|
|
|
|
|
def test_ledger_survives_reload(tmp_path: Path) -> None:
|
|
path = tmp_path / "l.json"
|
|
Ledger(path).record(_entry())
|
|
assert "abc" in Ledger(path)
|
|
|
|
|
|
def test_corrupt_ledger_fails_closed(tmp_path: Path) -> None:
|
|
"""A truncated ledger must stop the run, not re-import everything."""
|
|
path = tmp_path / "l.json"
|
|
path.write_text("{not json")
|
|
with pytest.raises(LedgerCorruptError):
|
|
Ledger(path)
|
|
|
|
|
|
def test_write_is_atomic(tmp_path: Path) -> None:
|
|
path = tmp_path / "l.json"
|
|
ledger = Ledger(path)
|
|
ledger.record(_entry())
|
|
assert not path.with_suffix(".tmp").exists()
|
|
assert json.loads(path.read_text())["abc"]["activity_id"] == 7
|
|
|
|
|
|
def test_non_dict_ledger_is_ignored(tmp_path: Path) -> None:
|
|
"""A JSON list is valid JSON but not a ledger; treat it as empty."""
|
|
path = tmp_path / "l.json"
|
|
path.write_text("[]")
|
|
assert len(Ledger(path)) == 0
|
|
|
|
|
|
def test_activity_start_reads_local_wall_time() -> None:
|
|
"""RunnerUp names exports in local time, so 00:58 is the previous day UTC."""
|
|
parsed = ledger.activity_start(Path("RunnerUp_2026-08-14-00-58-45_Running.tcx"))
|
|
assert parsed is not None
|
|
assert parsed.tzinfo is not None
|
|
expected = datetime(
|
|
2026, 8, 14, 0, 58, 45, tzinfo=datetime.now().astimezone().tzinfo
|
|
).astimezone(timezone.utc)
|
|
assert parsed == expected
|
|
|
|
|
|
def test_activity_start_ignores_the_device_model() -> None:
|
|
plain = ledger.activity_start(Path("RunnerUp_2026-08-22-23-51-04_Running.tcx"))
|
|
with_model = ledger.activity_start(
|
|
Path("RunnerUp_Pixel_6a_2026-08-22-23-51-04_Running.gpx")
|
|
)
|
|
assert plain == with_model
|
|
|
|
|
|
def test_activity_start_of_an_unparseable_name_is_none() -> None:
|
|
assert ledger.activity_start(Path("whatever.tcx")) is None
|
|
|
|
|
|
def test_activity_start_rejects_a_non_timestamp() -> None:
|
|
assert ledger.activity_start(Path("RunnerUp_notatimestamp_Running.tcx")) is None
|
|
|
|
|
|
def test_activity_start_rejects_an_impossible_date() -> None:
|
|
assert (
|
|
ledger.activity_start(Path("RunnerUp_2026-13-45-99-99-99_Running.tcx")) is None
|
|
)
|
|
|
|
|
|
def test_activity_start_rejects_wrong_field_count() -> None:
|
|
assert ledger.activity_start(Path("RunnerUp_2026-08-22_Running.tcx")) is None
|