Files
MeshTerm/tests/test_repository.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

285 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:
"""A Repository on a scratch database, closed when the test finishes."""
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"]