mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:47:02 +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
173 lines
5.3 KiB
Python
173 lines
5.3 KiB
Python
"""Inbox scanning and the adb fallback.
|
|
|
|
The fallback is read-only with respect to the phone: screen-locker reads the
|
|
same directory as its screen-unlock gate, so nothing here may delete or
|
|
rewrite those files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from python_pkg.endurain_import import sources
|
|
|
|
|
|
def test_inbox_lists_only_activity_files(tmp_path: Path) -> None:
|
|
(tmp_path / "a.tcx").write_text("x")
|
|
(tmp_path / "b.gpx").write_text("x")
|
|
(tmp_path / "c.fit").write_text("x")
|
|
(tmp_path / "notes.txt").write_text("x")
|
|
names = {p.name for p in sources.inbox_files(tmp_path)}
|
|
assert names == {"a.tcx", "b.gpx", "c.fit"}
|
|
|
|
|
|
def test_inbox_skips_processed_subdir(tmp_path: Path) -> None:
|
|
(tmp_path / "processed").mkdir()
|
|
(tmp_path / "processed" / "old.tcx").write_text("x")
|
|
assert sources.inbox_files(tmp_path) == []
|
|
|
|
|
|
def test_missing_inbox_is_not_an_error(tmp_path: Path) -> None:
|
|
assert sources.inbox_files(tmp_path / "nope") == []
|
|
|
|
|
|
def test_inbox_is_ordered_oldest_first(tmp_path: Path) -> None:
|
|
first = tmp_path / "first.tcx"
|
|
second = tmp_path / "second.tcx"
|
|
first.write_text("x")
|
|
second.write_text("x")
|
|
import os
|
|
|
|
os.utime(first, (1000, 1000))
|
|
os.utime(second, (2000, 2000))
|
|
assert [p.name for p in sources.inbox_files(tmp_path)] == [
|
|
"first.tcx",
|
|
"second.tcx",
|
|
]
|
|
|
|
|
|
def test_attached_serials_parses_adb_devices(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
sources,
|
|
"_adb",
|
|
lambda _a, _s=None: (True, "List of devices attached\nABC123\tdevice\n"),
|
|
)
|
|
assert sources.attached_serials() == ["ABC123"]
|
|
|
|
|
|
def test_unauthorised_device_is_not_listed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
sources,
|
|
"_adb",
|
|
lambda _a, _s=None: (True, "List of devices attached\nABC123\tunauthorized\n"),
|
|
)
|
|
assert sources.attached_serials() == []
|
|
|
|
|
|
def test_pull_skips_when_no_device(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.setattr(sources, "resolve_serial", lambda: None)
|
|
assert sources.pull_from_phone(tmp_path) == []
|
|
|
|
|
|
def test_pull_ignores_already_present_files(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.setattr(sources, "resolve_serial", lambda: "ABC123")
|
|
monkeypatch.setattr(
|
|
sources, "_adb", lambda _a, _s=None: (True, "RunnerUp_ts_Running.tcx\n")
|
|
)
|
|
(tmp_path / "RunnerUp_ts_Running.tcx").write_text("already here")
|
|
assert sources.pull_from_phone(tmp_path) == []
|
|
|
|
|
|
def test_failed_pull_leaves_no_partial_file(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A half-transferred file must never look like a complete activity."""
|
|
monkeypatch.setattr(sources, "resolve_serial", lambda: "ABC123")
|
|
|
|
def _adb(args: list[str], _serial: str | None = None) -> tuple[bool, str]:
|
|
if args[0] == "shell":
|
|
return True, "RunnerUp_ts_Running.tcx\n"
|
|
return False, "pull failed"
|
|
|
|
monkeypatch.setattr(sources, "_adb", _adb)
|
|
assert sources.pull_from_phone(tmp_path) == []
|
|
assert list(tmp_path.iterdir()) == []
|
|
|
|
|
|
def test_adb_handles_missing_binary(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A machine with no adb reports 'no device', it does not crash the run."""
|
|
|
|
def _boom(*_a: object, **_k: object) -> None:
|
|
message = "adb not found"
|
|
raise OSError(message)
|
|
|
|
monkeypatch.setattr(subprocess, "run", _boom)
|
|
ok, out = sources._adb(["devices"])
|
|
assert not ok
|
|
assert "adb not found" in out
|
|
|
|
|
|
def test_adb_handles_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _slow(*_a: object, **_k: object) -> None:
|
|
raise subprocess.TimeoutExpired(cmd="adb", timeout=1)
|
|
|
|
monkeypatch.setattr(subprocess, "run", _slow)
|
|
ok, _ = sources._adb(["devices"])
|
|
assert not ok
|
|
|
|
|
|
def test_adb_returns_output_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class _Proc:
|
|
returncode = 0
|
|
stdout = "out"
|
|
stderr = ""
|
|
|
|
monkeypatch.setattr(subprocess, "run", lambda *_a, **_k: _Proc())
|
|
ok, out = sources._adb(["devices"])
|
|
assert ok
|
|
assert out == "out"
|
|
|
|
|
|
def test_no_serials_when_adb_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(sources, "_adb", lambda _a, _s=None: (False, "boom"))
|
|
assert sources.attached_serials() == []
|
|
|
|
|
|
def test_pull_reports_unlistable_directory(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.setattr(sources, "resolve_serial", lambda: "ABC123")
|
|
monkeypatch.setattr(sources, "_adb", lambda _a, _s=None: (False, "no such dir"))
|
|
assert sources.pull_from_phone(tmp_path) == []
|
|
|
|
|
|
def test_successful_pull_moves_into_place(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.setattr(sources, "resolve_serial", lambda: "ABC123")
|
|
|
|
def _adb(args: list[str], _serial: str | None = None) -> tuple[bool, str]:
|
|
if args[0] == "shell":
|
|
return True, "RunnerUp_ts_Running.tcx\nnotes.txt\n"
|
|
Path(args[2]).write_text("pulled")
|
|
return True, ""
|
|
|
|
monkeypatch.setattr(sources, "_adb", _adb)
|
|
pulled = sources.pull_from_phone(tmp_path)
|
|
assert [p.name for p in pulled] == ["RunnerUp_ts_Running.tcx"]
|
|
assert (tmp_path / "RunnerUp_ts_Running.tcx").read_text() == "pulled"
|
|
assert not list(tmp_path.glob(".*partial"))
|