The target picker was a hand-rolled two-section list ("Recently traced",
then "Contacts") — the one contact enumerator in the app that didn't ride
the shared ContactListScreen. Move it onto that widget so it sorts,
filters, and steers exactly like the Contacts screen and the courier
recipient: full NAME · HEARD · PKTS · KEY lanes, Ctrl+arrow sort ring,
type-to-filter. Every node type is listed — a trace answers "can I reach
this node?" for a repeater or room, not just a companion.
The recency the old section carried becomes a first-class sortable lane:
ContactListScreen grows an optional TRACED column (opt-in via show_traced)
between name and heard, showing how long ago each node was last traced,
recency-heat coloured. It is the picker's default sort, descending, so the
most-recently-traced node leads and never-traced ones gather at the
bottom. A new repository query (target_last_traced) feeds it, folded onto
contacts the same way the old recent-targets list was.
The free-text prompt stays as the fallback when there are no contacts to
list — still the only way to trace a bare key prefix off-list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
283 lines
10 KiB
Python
283 lines
10 KiB
Python
"""Tests for the Repository persistence layer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from meshterm.core.models import PATH_TRACE_TARGET, Hop, TraceResult
|
|
from meshterm.persistence.repository import Repository
|
|
|
|
|
|
@pytest.fixture()
|
|
def repo(tmp_path: Path) -> Repository:
|
|
r = Repository(tmp_path / "test.db")
|
|
yield r
|
|
r.close()
|
|
|
|
|
|
def _make_trace(
|
|
target: str,
|
|
*hops: tuple[str | None, float],
|
|
success: bool = True,
|
|
rtt: float | None = 42.0,
|
|
tx: int | None = 11,
|
|
ts: datetime | None = None,
|
|
) -> TraceResult:
|
|
"""Build a trace with full control over fields that matter for persistence."""
|
|
return TraceResult(
|
|
target=target,
|
|
success=success,
|
|
hops=[Hop(i, node, snr) for i, (node, snr) in enumerate(hops)],
|
|
round_trip_ms=rtt,
|
|
tx_power=tx,
|
|
timestamp=ts or datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc),
|
|
)
|
|
|
|
|
|
# -- record + hydrate round-trip -------------------------------------------
|
|
|
|
|
|
def test_record_and_hydrate_preserves_all_fields(repo: Repository) -> None:
|
|
"""Every TraceResult field survives a write→read cycle."""
|
|
ts = datetime(2026, 6, 1, 8, 30, 0, tzinfo=timezone.utc)
|
|
trace = _make_trace("Alice", ("relay", 3.5), ("Alice", -2.0), (None, 1.0),
|
|
rtt=88.5, tx=7, ts=ts)
|
|
run = repo.start_run("trace", {})
|
|
repo.record_trace(run, trace)
|
|
|
|
got = repo.recent_traces("Alice")[0]
|
|
assert got.target == "Alice"
|
|
assert got.success is True
|
|
assert got.round_trip_ms == 88.5
|
|
assert got.tx_power == 7
|
|
assert got.timestamp == ts
|
|
assert [(h.index, h.node, h.snr) for h in got.hops] == [
|
|
(0, "relay", 3.5),
|
|
(1, "Alice", -2.0),
|
|
(2, None, 1.0),
|
|
]
|
|
|
|
|
|
def test_failed_trace_round_trips(repo: Repository) -> None:
|
|
"""A failed trace (no hops, no RTT) persists and reads back correctly."""
|
|
trace = _make_trace("Bob", success=False, rtt=None, tx=None)
|
|
run = repo.start_run("trace", {})
|
|
repo.record_trace(run, trace)
|
|
|
|
got = repo.recent_traces("Bob")[0]
|
|
assert got.success is False
|
|
assert got.hops == []
|
|
assert got.round_trip_ms is None
|
|
assert got.tx_power is None
|
|
assert got.path_hash_bytes is None # no hashes to derive a width from
|
|
|
|
|
|
def test_hydrated_traces_recover_the_path_hash_width(repo: Repository) -> None:
|
|
"""The stored hop hashes carry the trace's width, so rehydration recovers it.
|
|
|
|
Without this, a previous trace's route line renders every hash — including our
|
|
own device's full 64-char public key — at absurd lengths.
|
|
"""
|
|
run = repo.start_run("trace", {})
|
|
trace = _make_trace("Alice", ("3d63", 3.5), ("f2c2", -2.0), (None, 1.0))
|
|
repo.record_trace(run, trace)
|
|
|
|
assert repo.latest_trace("Alice").path_hash_bytes == 2
|
|
assert repo.recent_traces("Alice")[0].path_hash_bytes == 2
|
|
|
|
|
|
# -- recent_traces ---------------------------------------------------------
|
|
|
|
|
|
def test_recent_traces_returns_newest_first(repo: Repository) -> None:
|
|
"""Traces come back in reverse insertion order."""
|
|
run = repo.start_run("trace", {})
|
|
for i in range(5):
|
|
repo.record_trace(run, _make_trace("X", ("hop", float(i))))
|
|
|
|
traces = repo.recent_traces("X")
|
|
snrs = [t.hops[0].snr for t in traces]
|
|
assert snrs == [4.0, 3.0, 2.0, 1.0, 0.0]
|
|
|
|
|
|
def test_recent_traces_respects_limit(repo: Repository) -> None:
|
|
"""The limit parameter caps the number of returned traces."""
|
|
run = repo.start_run("trace", {})
|
|
for _ in range(10):
|
|
repo.record_trace(run, _make_trace("X", ("h", 1.0)))
|
|
|
|
assert len(repo.recent_traces("X", limit=3)) == 3
|
|
|
|
|
|
def test_recent_traces_filters_by_target(repo: Repository) -> None:
|
|
"""Only traces to the requested target are returned."""
|
|
run = repo.start_run("trace", {})
|
|
repo.record_trace(run, _make_trace("Alice", ("r", 1.0)))
|
|
repo.record_trace(run, _make_trace("Bob", ("r", 2.0)))
|
|
repo.record_trace(run, _make_trace("Alice", ("r", 3.0)))
|
|
|
|
got = repo.recent_traces("Alice")
|
|
assert len(got) == 2
|
|
assert all(t.target == "Alice" for t in got)
|
|
|
|
|
|
def test_recent_traces_includes_failures(repo: Repository) -> None:
|
|
"""Both successful and failed traces are returned (failures are informative)."""
|
|
run = repo.start_run("trace", {})
|
|
repo.record_trace(run, _make_trace("X", ("h", 1.0), success=True))
|
|
repo.record_trace(run, _make_trace("X", success=False))
|
|
|
|
got = repo.recent_traces("X")
|
|
assert len(got) == 2
|
|
statuses = {t.success for t in got}
|
|
assert statuses == {True, False}
|
|
|
|
|
|
def test_recent_traces_empty_target(repo: Repository) -> None:
|
|
"""Querying a target with no stored traces returns an empty list."""
|
|
assert repo.recent_traces("NoSuchNode") == []
|
|
|
|
|
|
# -- traced_targets --------------------------------------------------------
|
|
|
|
|
|
def test_traced_targets_recency_ordered_and_capped(repo: Repository) -> None:
|
|
"""Targets come back most recently traced first, deduplicated, and capped.
|
|
|
|
The all-time trace count must not matter: a heavily-traced old target (Alice here)
|
|
ranks by its *latest* trace, so stale favourites and leftover mock names age out of
|
|
the picker's short "Recently traced" list instead of squatting on top forever.
|
|
"""
|
|
run = repo.start_run("trace", {})
|
|
for _ in range(8):
|
|
repo.record_trace(run, _make_trace("Alice", ("r", 1.0)))
|
|
for name in ["Bob", "Carol", "Dave", "Erin", "Frank"]:
|
|
repo.record_trace(run, _make_trace(name, ("r", 1.0)))
|
|
repo.record_trace(run, _make_trace("Bob", ("r", 1.0))) # Bob traced again just now
|
|
|
|
assert repo.traced_targets() == ["Bob", "Frank", "Erin", "Dave", "Carol"]
|
|
assert repo.traced_targets(limit=2) == ["Bob", "Frank"]
|
|
|
|
|
|
def test_traced_targets_empty_db(repo: Repository) -> None:
|
|
"""No traces → empty list."""
|
|
assert repo.traced_targets() == []
|
|
|
|
|
|
def test_traced_targets_excludes_path_walks(repo: Repository) -> None:
|
|
"""Path walks record under the ``(path)`` sentinel and never become pickable targets."""
|
|
run = repo.start_run("trace", {})
|
|
repo.record_trace(run, _make_trace("Alice", ("r", 1.0)))
|
|
repo.record_trace(run, _make_trace(PATH_TRACE_TARGET, ("r", 2.0)))
|
|
|
|
assert repo.traced_targets() == ["Alice"]
|
|
# …but the walks still seed the path screen's previous-route line.
|
|
latest = repo.latest_trace(PATH_TRACE_TARGET)
|
|
assert latest is not None and latest.target == PATH_TRACE_TARGET
|
|
|
|
|
|
def test_target_trace_counts_tallies_successes_and_totals(repo: Repository) -> None:
|
|
"""Per target, (successes, total) over every trace — failures included, path walks not."""
|
|
run = repo.start_run("trace", {})
|
|
repo.record_trace(run, _make_trace("Alice", ("r", 1.0), success=True))
|
|
repo.record_trace(run, _make_trace("Alice", success=False))
|
|
repo.record_trace(run, _make_trace("Alice", ("r", 2.0), success=True))
|
|
repo.record_trace(run, _make_trace("Bob", ("r", 1.0), success=True))
|
|
repo.record_trace(run, _make_trace(PATH_TRACE_TARGET, ("r", 3.0), success=True))
|
|
|
|
counts = repo.target_trace_counts()
|
|
assert counts["Alice"] == (2, 3) # two of three came home
|
|
assert counts["Bob"] == (1, 1)
|
|
assert PATH_TRACE_TARGET not in counts # path walks name no target
|
|
|
|
|
|
def test_target_last_traced_keeps_latest_per_target(repo: Repository) -> None:
|
|
"""Per target, the most recent trace time — failures counted, path walks excluded."""
|
|
run = repo.start_run("trace", {})
|
|
early = datetime(2026, 1, 10, 9, 0, tzinfo=timezone.utc)
|
|
late = datetime(2026, 1, 12, 18, 0, tzinfo=timezone.utc)
|
|
repo.record_trace(run, _make_trace("Alice", ("r", 1.0), ts=early))
|
|
repo.record_trace(run, _make_trace("Alice", success=False, ts=late)) # newer, timed out
|
|
repo.record_trace(run, _make_trace("Bob", ("r", 1.0), ts=early))
|
|
repo.record_trace(run, _make_trace(PATH_TRACE_TARGET, ("r", 3.0), ts=late))
|
|
|
|
last = repo.target_last_traced()
|
|
assert last["Alice"] == late # the failed-but-newer trace wins — you still traced it
|
|
assert last["Bob"] == early
|
|
assert PATH_TRACE_TARGET not in last # path walks name no target
|
|
|
|
|
|
# -- latest_trace edge cases -----------------------------------------------
|
|
|
|
|
|
def test_latest_trace_success_only_skips_failures(repo: Repository) -> None:
|
|
"""success_only=True skips failed traces, even if they're newer."""
|
|
run = repo.start_run("trace", {})
|
|
repo.record_trace(run, _make_trace("X", ("h", 1.0), success=True))
|
|
repo.record_trace(run, _make_trace("X", success=False))
|
|
|
|
got = repo.latest_trace("X", success_only=True)
|
|
assert got is not None
|
|
assert got.success is True
|
|
|
|
|
|
def test_latest_trace_success_only_false_includes_failures(repo: Repository) -> None:
|
|
"""success_only=False returns the newest trace regardless of outcome."""
|
|
run = repo.start_run("trace", {})
|
|
repo.record_trace(run, _make_trace("X", ("h", 1.0), success=True))
|
|
repo.record_trace(run, _make_trace("X", success=False))
|
|
|
|
got = repo.latest_trace("X", success_only=False)
|
|
assert got is not None
|
|
assert got.success is False
|
|
|
|
|
|
# -- runs ------------------------------------------------------------------
|
|
|
|
|
|
def test_run_lifecycle(repo: Repository) -> None:
|
|
"""A run transitions from running → ok with a summary."""
|
|
run_id = repo.start_run("route-map", {"target": "Alice"}, profile="field")
|
|
runs = repo.list_runs()
|
|
assert runs[0].status == "running"
|
|
assert runs[0].tool == "route-map"
|
|
assert runs[0].profile == "field"
|
|
assert runs[0].finished_at is None
|
|
|
|
repo.finish_run(run_id, "ok", {"stability": 0.85})
|
|
runs = repo.list_runs()
|
|
assert runs[0].status == "ok"
|
|
assert runs[0].finished_at is not None
|
|
assert runs[0].summary == {"stability": 0.85}
|
|
|
|
|
|
def test_finish_run_error_without_summary(repo: Repository) -> None:
|
|
"""An errored run stores status without a summary."""
|
|
run_id = repo.start_run("trace", {})
|
|
repo.finish_run(run_id, "error")
|
|
|
|
runs = repo.list_runs()
|
|
assert runs[0].status == "error"
|
|
assert runs[0].summary is None
|
|
|
|
|
|
def test_list_runs_respects_limit(repo: Repository) -> None:
|
|
"""list_runs caps results at the requested limit."""
|
|
for i in range(10):
|
|
repo.start_run("trace", {"i": i})
|
|
|
|
assert len(repo.list_runs(limit=3)) == 3
|
|
|
|
|
|
def test_list_runs_newest_first(repo: Repository) -> None:
|
|
"""Runs come back in reverse insertion order."""
|
|
repo.start_run("first", {})
|
|
repo.start_run("second", {})
|
|
repo.start_run("third", {})
|
|
|
|
tools = [r.tool for r in repo.list_runs()]
|
|
assert tools == ["third", "second", "first"]
|