Files
MeshTerm/tests/test_repository.py
Jean-Pierre MartineauandClaude Opus 5.5 9ae0947717 A shared name goes to the newest key unless two keys used it within a week
Greying every name that two keys ever used was too strict: a reflash with an
erase makes a new key, and 34 names on this mesh have one, JP's own among
them. Smog went grey this morning because of two keys silent since September.

The newest key now gets the name, and the name is grey only when another key
used it within name_overlap_days (a new Display preference, "Shared names",
default 7) of it: two live claimants. A contact that was never heard never
wins. On this machine's real data, LOG.IC 3R goes to its active key 47368a7c,
Smog to fa331c83, and 8 of 883 names stay grey.

The stored names come with their times from the one scan node_names() already
ran (node_names_with_times), so opening a chat costs no second scan.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 09:49:21 -04:00

314 lines
12 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for the Repository layer that stores data."""
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 temporary database. It closes when the test ends."""
r = Repository(tmp_path / "test.db")
yield r
r.close()
def test_node_names_with_times_gives_each_latest_name_and_when_it_was_heard(
repo: Repository,
) -> None:
"""One scan gives the names and their times. ``node_names`` is the first half of it."""
from meshterm.core.models import Observation
run = repo.start_run("monitor", {})
early = datetime(2026, 9, 20, 12, 0, tzinfo=timezone.utc)
late = datetime(2026, 10, 10, 12, 0, tzinfo=timezone.utc)
repo.record_observation(run, Observation(node="aa11bb22cc33", name="Smog", observed_at=early))
repo.record_observation(run, Observation(node="aa11bb22cc33", name="Smog 2", observed_at=late))
repo.record_observation(run, Observation(node="dd44ee55ff66", name="Bob", observed_at=early))
names, times = repo.node_names_with_times()
assert names == {"aa11bb22cc33": "Smog 2", "dd44ee55ff66": "Bob"}
assert times == {"aa11bb22cc33": late, "dd44ee55ff66": early}
assert repo.node_names() == names
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. The arguments set each field that the storage uses."""
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:
"""Each TraceResult field stays the same after a write and a read."""
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) is stored, and it 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 give a width
def test_hydrated_traces_recover_the_path_hash_width(repo: Repository) -> None:
"""The stored hop hashes have the width of the trace, so the hydration recovers it.
If the width is lost, the route line of a previous trace shows each hash at the wrong
length. This includes the full 64-character public key of our device.
"""
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 return in reverse order of insertion."""
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 sets the maximum number of traces that return."""
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 the traces to the requested target return."""
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:
"""Successful traces and failed traces both return, because a failure gives information."""
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:
"""A query for 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 return with the most recently traced first, with no duplicates, up to a limit.
The total count of traces must not matter. An old target with many traces (Alice here)
ranks by its latest trace. Thus old favourites and mock names that remain go out of the
short "Recently traced" list of the picker. They do not stay at the top for ever.
"""
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))) # the user traced Bob 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:
"""If there are no traces, the list is empty."""
assert repo.traced_targets() == []
def test_traced_targets_excludes_path_walks(repo: Repository) -> None:
"""Path walks are stored under the ``(path)`` sentinel.
They never become targets that the user can select.
"""
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 supply the line of the previous route on the path screen.
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:
"""For each target, the count is (successes, total) of all traces.
It includes failures, but not path walks.
"""
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 traces returned
assert counts["Bob"] == (1, 1)
assert PATH_TRACE_TARGET not in counts # a path walk has no target
def test_target_last_traced_keeps_latest_per_target(repo: Repository) -> None:
"""For each target, the time of the most recent trace.
It counts failures, but not path walks.
"""
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, and it 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 newer trace wins, also when it failed
assert last["Bob"] == early
assert PATH_TRACE_TARGET not in last # a path walk has no target
# -- latest_trace edge cases -----------------------------------------------
def test_latest_trace_success_only_skips_failures(repo: Repository) -> None:
"""success_only=True skips failed traces, also when they are 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, with any result."""
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 changes its status from running to ok, and gets 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:
"""A run that has an error stores its status and no 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 returns no more results than 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 return in reverse order of insertion."""
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"]