Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
286 lines
11 KiB
Python
286 lines
11 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""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"]
|