Two correctness fixes for remote-admin TX optimization: 1. Reverse/round-trip measurement path. The optimizer traced a one-way path ending at the (possibly far) target and waited for the target itself to originate the trace reply, which never returns when the target is only reachable via other repeaters, so every trace timed out. Now we trace out to the target and back (the MeshCore A,B,A convention) so a reachable near node answers; the target only forwards. Its received SNR is read from its hop by hash (the round trip turn-around) rather than by position. 2. Restore original TX on no result. If not one trace gets through at any level there is no meaningful winner, so leave the node at its original power (applied=False) and warn, instead of writing a bogus value. Mock updated to model each hop SNR from the node that transmitted into it and to drop traces on the bottleneck hop. Added round-trip / target-hash tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
587 lines
22 KiB
Python
587 lines
22 KiB
Python
"""Smoke tests for the skeleton: models, mock device, service, and persistence.
|
||
|
||
These run without hardware against the :class:`MockDevice` simulator.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from meshtools.core.connection import DeviceCommandError, MockDevice, clamp_tx_power
|
||
from meshtools.core.models import Contact, TraceResult, TraceStats
|
||
from meshtools.persistence.repository import Repository
|
||
from meshtools.services import trace_runner
|
||
|
||
|
||
def test_clamp_tx_power() -> None:
|
||
"""TX power clamps to the supported range."""
|
||
assert clamp_tx_power(-5) == 1
|
||
assert clamp_tx_power(99) == 22
|
||
assert clamp_tx_power(14) == 14
|
||
|
||
|
||
def test_trace_stats_aggregation() -> None:
|
||
"""Robust stats reflect successes and bottleneck SNR."""
|
||
from meshtools.core.models import Hop
|
||
|
||
traces = [
|
||
TraceResult(target="x", success=True, hops=[Hop(0, "a", 5.0), Hop(1, "b", -2.0)]),
|
||
TraceResult(target="x", success=False),
|
||
TraceResult(target="x", success=True, hops=[Hop(0, "a", 7.0), Hop(1, "b", 0.0)]),
|
||
]
|
||
stats = TraceStats.from_traces("x", traces)
|
||
assert stats.samples == 3
|
||
assert stats.successes == 2
|
||
assert stats.success_rate == pytest.approx(2 / 3)
|
||
assert stats.median_min_snr == pytest.approx(-1.0) # median of [-2.0, 0.0]
|
||
|
||
|
||
def test_parse_trace_path_mixes_names_and_hex() -> None:
|
||
"""Names and hex mix; the hex width is preserved and names truncate to match."""
|
||
contacts = [Contact(name="Alice", key_prefix="d4e5f6a7")]
|
||
# 1-byte hops: hex, a contact name (case-insensitively), hex again
|
||
assert trace_runner.parse_trace_path("3d, alice ,f2", contacts) == "3d,d4,f2"
|
||
# the width comes from the hex tokens you type — no truncation to 1 byte
|
||
assert trace_runner.parse_trace_path("a1b2c3", contacts) == "a1b2c3"
|
||
|
||
|
||
def test_parse_trace_path_preserves_three_byte_width() -> None:
|
||
"""A 3-byte (6 hex) width is kept, and names truncate to that width."""
|
||
contacts = [Contact(name="Alice", key_prefix="d4e5f6a7b8c9")]
|
||
assert (
|
||
trace_runner.parse_trace_path("3d5f7a,Alice,f2a1b3", contacts)
|
||
== "3d5f7a,d4e5f6,f2a1b3"
|
||
)
|
||
|
||
|
||
def test_parse_trace_path_rejects_mixed_widths() -> None:
|
||
"""Hex hops must all use the same number of bytes."""
|
||
with pytest.raises(ValueError):
|
||
trace_runner.parse_trace_path("3d,a1b2c3", contacts=[])
|
||
|
||
|
||
def test_path_hash_flags_power_of_two_only() -> None:
|
||
"""Trace flags encode the hash width as 1 << s, so only 1/2/4/8 bytes map."""
|
||
assert trace_runner.path_hash_flags(1) == 0
|
||
assert trace_runner.path_hash_flags(2) == 1
|
||
assert trace_runner.path_hash_flags(4) == 2
|
||
assert trace_runner.path_hash_flags(8) == 3
|
||
assert trace_runner.path_hash_flags(3) is None
|
||
|
||
|
||
def test_parse_trace_hops_reads_path_snr() -> None:
|
||
"""Per-hop SNR comes from payload['path'] dicts; the final hash-less node counts."""
|
||
from meshtools.core.connection import parse_trace_hops
|
||
|
||
payload = {
|
||
"path": [
|
||
{"hash": "3d", "snr": 4.5},
|
||
{"hash": "f2", "snr": -2.0},
|
||
{"snr": 1.25}, # final node: our device, no hash
|
||
]
|
||
}
|
||
hops = parse_trace_hops(payload)
|
||
assert [h.node for h in hops] == ["3d", "f2", None]
|
||
assert [h.snr for h in hops] == [4.5, -2.0, 1.25]
|
||
assert [h.index for h in hops] == [0, 1, 2]
|
||
|
||
|
||
def test_parse_trace_hops_empty_without_path() -> None:
|
||
"""A reply with no parsed path yields no hops (SNR shows as n/a)."""
|
||
from meshtools.core.connection import parse_trace_hops
|
||
|
||
assert parse_trace_hops({}) == []
|
||
|
||
|
||
def test_route_text_annotates_nodes_with_command_width_hash() -> None:
|
||
"""The route shows each node's hash at the command's path-hash width."""
|
||
from meshtools.core.models import Hop
|
||
from meshtools.ui.widgets import _route_text
|
||
|
||
resolve = trace_runner.make_node_resolver(
|
||
[Contact(name="Alice", public_key="3d63c6" + "00" * 26, key_prefix="3d63c6429436")]
|
||
)
|
||
# Known node (resolves to Alice) then the reply returning to our device (node=None).
|
||
result = TraceResult(
|
||
target="Alice",
|
||
success=True,
|
||
hops=[Hop(0, "3d63c6", 12.0), Hop(1, None, 12.0)],
|
||
path_hash_bytes=2, # command used 2-byte hashes
|
||
)
|
||
plain = _route_text(result, "Me", resolve).plain
|
||
assert "Alice" in plain
|
||
assert "(3d63)" in plain # truncated to 2 bytes, not the full 3d63c6
|
||
assert "3d63c6" not in plain
|
||
assert plain.count("Me") == 2 # our device at both ends, carrying no hash
|
||
assert "Me (" not in plain.replace(" ", " ") # device never annotated
|
||
|
||
|
||
def test_route_text_annotates_our_device_with_hash() -> None:
|
||
"""Given our key, both endpoints (us) carry our hash at the command width."""
|
||
from meshtools.core.models import Hop
|
||
from meshtools.ui.widgets import _route_text
|
||
|
||
result = TraceResult(
|
||
target="x",
|
||
success=True,
|
||
hops=[Hop(0, "3d63", 12.0), Hop(1, None, 12.0)],
|
||
path_hash_bytes=2, # command used 2-byte hashes
|
||
)
|
||
plain = _route_text(
|
||
result, "Me", device_hash="a1b2c3" + "00" * 29
|
||
).plain.replace("\xa0", " ")
|
||
assert plain.count("Me (a1b2)") == 2 # our device at both ends, carrying our hash
|
||
assert "a1b2c3" not in plain # truncated to the command's 2-byte width
|
||
|
||
|
||
def test_traces_table_annotates_links_with_hashes() -> None:
|
||
"""The per-trace From→To column shows ``name (hash)``, including our device."""
|
||
from meshtools.core.models import Hop
|
||
from meshtools.ui.widgets import traces_table
|
||
|
||
resolve = trace_runner.make_node_resolver(
|
||
[Contact(name="Alice", public_key="3d63c6" + "00" * 26, key_prefix="3d63c6429436")]
|
||
)
|
||
traces = [
|
||
TraceResult(
|
||
target="Alice",
|
||
success=True,
|
||
hops=[Hop(0, "3d63c6", 12.0), Hop(1, None, 9.0)],
|
||
path_hash_bytes=2, # command used 2-byte hashes
|
||
)
|
||
]
|
||
table = traces_table(traces, "Me", resolve, device_hash="a1b2c3" + "00" * 29)
|
||
links = [cell.plain.replace("\xa0", " ") for cell in table.columns[1].cells]
|
||
assert any("Me (a1b2)" in link for link in links) # our device carries its hash
|
||
assert any("Alice (3d63)" in link for link in links) # truncated to the 2-byte width
|
||
assert not any("3d63c6" in link for link in links)
|
||
|
||
|
||
def test_route_text_unknown_node_shows_hash_only() -> None:
|
||
"""An unresolved node shows just its hash, truncated to the command width."""
|
||
from meshtools.core.models import Hop
|
||
from meshtools.ui.widgets import _route_text
|
||
|
||
result = TraceResult(
|
||
target="x",
|
||
success=True,
|
||
hops=[Hop(0, "aa11bb", 12.0), Hop(1, None, 12.0)],
|
||
path_hash_bytes=1, # 1-byte width
|
||
)
|
||
plain = _route_text(result, "Me").plain.replace(" ", " ")
|
||
assert "→ aa →" in plain # 1-byte hash, no name, no parens
|
||
assert "aa11bb" not in plain
|
||
|
||
|
||
def test_parse_trace_path_rejects_unknown_token() -> None:
|
||
"""A token that is neither a known contact nor valid hex is an error."""
|
||
with pytest.raises(ValueError):
|
||
trace_runner.parse_trace_path("nope", contacts=[])
|
||
with pytest.raises(ValueError):
|
||
trace_runner.parse_trace_path(" ", contacts=[])
|
||
|
||
|
||
async def test_mock_device_honors_forced_path() -> None:
|
||
"""A forced path drives the repeater hops; a return-to-us hop is appended."""
|
||
device = MockDevice()
|
||
await device.connect()
|
||
result = await device.run_trace("Alice", path="3d,f2,3d")
|
||
# Three forced repeater hops, plus the hash-less hop for the reply returning to us.
|
||
assert result.hop_count == 4
|
||
assert [h.node for h in result.hops] == ["3d", "f2", "3d", None]
|
||
|
||
|
||
async def test_mock_device_blank_path_traces() -> None:
|
||
"""A blank/auto path still produces a successful, non-empty trace."""
|
||
device = MockDevice()
|
||
await device.connect()
|
||
# Try several times since the simulator drops very weak links occasionally.
|
||
results = [await device.run_trace("Alice") for _ in range(10)]
|
||
assert any(r.success and r.hops for r in results)
|
||
|
||
|
||
def test_trace_edges_endpoints_are_our_device() -> None:
|
||
"""The first edge originates at us and the last edge returns to us (#3/#4)."""
|
||
from meshtools.core.models import Hop
|
||
|
||
result = TraceResult(
|
||
target="Alice",
|
||
success=True,
|
||
hops=[Hop(0, "3d", 5.0), Hop(1, "f2", 1.0), Hop(2, None, -1.0)],
|
||
)
|
||
edges = result.edges(device_label="us")
|
||
assert [(e.origin, e.destination) for e in edges] == [
|
||
("us", "3d"),
|
||
("3d", "f2"),
|
||
("f2", "us"),
|
||
]
|
||
assert [e.snr for e in edges] == [5.0, 1.0, -1.0]
|
||
|
||
|
||
def test_trace_stats_reports_per_hop_medians() -> None:
|
||
"""Aggregation yields a median SNR for every hop position (#5)."""
|
||
from meshtools.core.models import Hop
|
||
|
||
traces = [
|
||
TraceResult(target="x", success=True, hops=[Hop(0, "a", 4.0), Hop(1, None, 0.0)]),
|
||
TraceResult(target="x", success=True, hops=[Hop(0, "a", 8.0), Hop(1, None, 2.0)]),
|
||
]
|
||
stats = TraceStats.from_traces("x", traces)
|
||
# Node identities are raw (None = our device) so a label is applied at render time.
|
||
assert [(h.origin, h.destination, h.median_snr) for h in stats.hop_snrs] == [
|
||
(None, "a", 6.0),
|
||
("a", None, 1.0),
|
||
]
|
||
|
||
|
||
async def test_mock_device_trace_is_unimodal_in_tx() -> None:
|
||
"""The simulator peaks near its optimal TX power (averaged over noise)."""
|
||
device = MockDevice(optimal_tx=14)
|
||
await device.connect()
|
||
|
||
async def avg_snr(tx: int) -> float:
|
||
await device.set_tx_power(tx)
|
||
stats = await trace_runner.measure(device, "Alice", samples=15, cooldown_s=0)
|
||
return stats.median_min_snr or -99.0
|
||
|
||
low, peak, high = await avg_snr(2), await avg_snr(14), await avg_snr(22)
|
||
assert peak > low
|
||
assert peak > high
|
||
|
||
|
||
async def _setup_link(optimal_remote_tx: int = 20):
|
||
"""Return a connected mock plus the (admin, target) contacts and forced path.
|
||
|
||
The path runs admin → target, so the optimizer tunes the admin node and reads the
|
||
SNR the target receives from it.
|
||
"""
|
||
device = MockDevice(optimal_remote_tx=optimal_remote_tx)
|
||
await device.connect()
|
||
contacts = await device.get_contacts()
|
||
admin = next(c for c in contacts if c.name == "Yagi-Repeater")
|
||
target = next(c for c in contacts if c.name == "Alice")
|
||
path = trace_runner.parse_trace_path(f"{admin.name},{target.name}", contacts)
|
||
assert await device.admin_login(admin, "admin")
|
||
return device, admin, target, path
|
||
|
||
|
||
async def test_admin_login_rejects_wrong_password() -> None:
|
||
"""A wrong admin password is refused, and the node stays un-tunable."""
|
||
device = MockDevice(admin_password="secret")
|
||
await device.connect()
|
||
admin = (await device.get_contacts())[0]
|
||
|
||
assert await device.admin_login(admin, "nope") is False
|
||
with pytest.raises(DeviceCommandError):
|
||
await device.set_remote_tx_power(admin, 18) # not logged in
|
||
assert await device.admin_login(admin, "secret") is True
|
||
await device.set_remote_tx_power(admin, 18) # now allowed
|
||
assert await device.get_remote_tx_power(admin) == 18
|
||
|
||
|
||
async def test_tx_optimizer_finds_simulator_peak(tmp_path: Path) -> None:
|
||
"""The optimizer converges near the simulated remote optimum and applies it."""
|
||
from meshtools.services import tx_optimizer
|
||
|
||
device, admin, target, path = await _setup_link(optimal_remote_tx=20)
|
||
|
||
result = await tx_optimizer.optimize_tx_power(
|
||
device, target.name, admin, path,
|
||
samples_per_level=8, coarse_step=3, cooldown_s=0,
|
||
)
|
||
assert abs(result.best_tx - 20) <= 3 # near the true remote peak
|
||
assert result.best_success_rate == 1.0 # reliability-first: the winner never drops
|
||
assert result.applied
|
||
assert await device.get_remote_tx_power(admin) == result.best_tx # left tuned
|
||
assert result.admin_node == "Yagi-Repeater"
|
||
|
||
|
||
async def test_tx_optimizer_no_apply_restores_original(tmp_path: Path) -> None:
|
||
"""With apply off, the node is left at the power it started with."""
|
||
from meshtools.services import tx_optimizer
|
||
|
||
device, admin, target, path = await _setup_link()
|
||
await device.set_remote_tx_power(admin, 15) # a known starting power
|
||
|
||
result = await tx_optimizer.optimize_tx_power(
|
||
device, target.name, admin, path,
|
||
samples_per_level=4, coarse_step=4, refine=False, verify=False,
|
||
apply=False, cooldown_s=0,
|
||
)
|
||
assert result.original_tx == 15
|
||
assert not result.applied
|
||
assert await device.get_remote_tx_power(admin) == 15 # restored, not the winner
|
||
|
||
|
||
def test_select_best_prefers_reliability_then_lowest_power() -> None:
|
||
"""A 100%-reliable level beats a flakier higher-SNR one; ties go to lower TX."""
|
||
from meshtools.core.models import TraceStats, TxLevelResult
|
||
from meshtools.services.tx_optimizer import select_best
|
||
|
||
def level(tx: int, snr: float, successes: int, samples: int = 5) -> TxLevelResult:
|
||
return TxLevelResult(
|
||
tx_power=tx, samples=samples, successes=successes, target_snr=snr,
|
||
score=snr, stats=TraceStats(target="t", samples=samples, successes=successes,
|
||
median_min_snr=snr, median_rtt_ms=None),
|
||
)
|
||
|
||
# A flaky level with a great SNR must not beat a perfectly reliable one.
|
||
flaky = level(26, snr=12.0, successes=3)
|
||
solid = level(20, snr=7.0, successes=5)
|
||
assert select_best([flaky, solid]).tx_power == 20
|
||
|
||
# Among reliable levels with SNR within tolerance, the lowest TX wins.
|
||
a = level(18, snr=7.4, successes=5)
|
||
b = level(22, snr=8.0, successes=5)
|
||
assert select_best([a, b], snr_tolerance=1.0).tx_power == 18
|
||
|
||
|
||
def test_round_trip_path_mirrors_back_to_a_reachable_node() -> None:
|
||
"""The trace path goes out to the target and back, so a near node answers."""
|
||
from meshtools.services.tx_optimizer import _round_trip_path
|
||
|
||
assert _round_trip_path(["3f", "f2"]) == "3f,f2,3f"
|
||
assert _round_trip_path(["3d", "3f", "f2"]) == "3d,3f,f2,3f,3d"
|
||
assert _round_trip_path(["f2"]) == "f2" # single hop: nothing to mirror
|
||
|
||
|
||
def test_trace_target_snr_matches_target_hop_not_position() -> None:
|
||
"""The target's SNR is read from its hash, even when it's the turn-around hop."""
|
||
from meshtools.core.models import Hop
|
||
from meshtools.services.tx_optimizer import trace_target_snr
|
||
|
||
# Round trip 3f -> f2 -> 3f -> us: the f2 hop (index 1) is what we want, not the
|
||
# later 3f hop or the hash-less return hop.
|
||
trace = TraceResult(
|
||
target="f2", success=True,
|
||
hops=[Hop(0, "3f", 4.0), Hop(1, "f2", 7.5), Hop(2, "3f", 3.0), Hop(3, None, 4.0)],
|
||
)
|
||
assert trace_target_snr(trace, "f2") == 7.5
|
||
assert trace_target_snr(TraceResult(target="f2", success=False), "f2") is None
|
||
|
||
|
||
def test_tx_plot_writes_html(tmp_path: Path) -> None:
|
||
"""The Plotly renderer produces a self-contained HTML file."""
|
||
import asyncio
|
||
|
||
from meshtools.services import tx_optimizer
|
||
from meshtools.viz.tx_plot import render_tx_optimization
|
||
|
||
async def _build():
|
||
device, admin, target, path = await _setup_link()
|
||
return await tx_optimizer.optimize_tx_power(
|
||
device, target.name, admin, path,
|
||
samples_per_level=4, coarse_step=4, refine=False, verify=False, cooldown_s=0,
|
||
)
|
||
|
||
result = asyncio.run(_build())
|
||
path = render_tx_optimization(result, tmp_path)
|
||
assert path.exists()
|
||
assert path.suffix == ".html"
|
||
assert path.stat().st_size > 1000 # plotly.js inlined → non-trivial size
|
||
|
||
|
||
class _Event:
|
||
def __init__(self, payload: dict) -> None:
|
||
self.payload = payload
|
||
|
||
def is_error(self) -> bool:
|
||
return False
|
||
|
||
|
||
def _mc_with(contacts: dict, path_hash_mode: int = 2):
|
||
"""Build a minimal mock ``MeshCore`` client exposing the trace commands used."""
|
||
|
||
class _Commands:
|
||
async def get_contacts(self) -> _Event:
|
||
return _Event(contacts)
|
||
|
||
async def get_path_hash_mode(self) -> int:
|
||
return path_hash_mode
|
||
|
||
class _MC:
|
||
commands = _Commands()
|
||
|
||
return _MC()
|
||
|
||
|
||
async def test_three_byte_route_appends_destination_hash() -> None:
|
||
"""A learned multi-hop route walks the repeaters then ends at the target (#1).
|
||
|
||
Two things matter for a non-power-of-two region (3-byte hashes): each 3-byte
|
||
routing hash collapses to its leading 2 bytes (the widest representable trace
|
||
width), and the destination's own hash is appended as the final hop — a trace
|
||
only replies when its destination is the last hop.
|
||
"""
|
||
from meshtools.core.connection import MeshCoreDevice
|
||
|
||
mc = _mc_with(
|
||
{
|
||
"Repeater": {
|
||
"adv_name": "Repeater",
|
||
"public_key": "aabbcc" + "00" * 29,
|
||
# Two hops, each a 3-byte routing hash (mode 2 => size 3).
|
||
"out_path": "112233445566",
|
||
"out_path_len": 2,
|
||
"out_path_hash_mode": 2,
|
||
}
|
||
}
|
||
)
|
||
|
||
device = MeshCoreDevice(port="COM-test")
|
||
resolved = await device._trace_path_to_contact(mc, "Repeater")
|
||
assert resolved is not None
|
||
path_bytes, flags = resolved
|
||
# Two repeater hops collapsed to 2 bytes, then the target's own 2-byte hash.
|
||
assert path_bytes == bytes.fromhex("1122" "4455" "aabb")
|
||
assert flags == trace_runner.path_hash_flags(2) # 2-byte width
|
||
|
||
|
||
async def test_direct_neighbor_resolves_to_destination_hash() -> None:
|
||
"""A direct neighbor (no learned route) resolves to a single destination hop.
|
||
|
||
Verified on hardware: these contacts report ``out_path_len == -1`` (no stored
|
||
route) yet answer a single-hop trace addressed to their own hash. With the
|
||
contact's mode unknown (-1) the region's path-hash mode (3-byte) sets the width,
|
||
collapsed to the representable 2 bytes.
|
||
"""
|
||
from meshtools.core.connection import MeshCoreDevice
|
||
|
||
mc = _mc_with(
|
||
{
|
||
"Neighbor": {
|
||
"adv_name": "Neighbor",
|
||
"public_key": "3d63c6" + "00" * 29,
|
||
# Direct neighbor as the firmware reports it: no learned route.
|
||
"out_path": "",
|
||
"out_path_len": -1,
|
||
"out_path_hash_mode": -1,
|
||
}
|
||
},
|
||
path_hash_mode=2,
|
||
)
|
||
|
||
device = MeshCoreDevice(port="COM-test")
|
||
resolved = await device._trace_path_to_contact(mc, "Neighbor")
|
||
assert resolved is not None
|
||
path_bytes, flags = resolved
|
||
# Just the target's own hash, region width 3 collapsed to a 2-byte trace hop.
|
||
assert path_bytes == bytes.fromhex("3d63")
|
||
assert flags == trace_runner.path_hash_flags(2)
|
||
|
||
|
||
async def test_unknown_contact_resolves_to_none() -> None:
|
||
"""An unrecognized target yields ``None`` so the trace can fall back to path-less."""
|
||
from meshtools.core.connection import MeshCoreDevice
|
||
|
||
mc = _mc_with(
|
||
{
|
||
"Somebody": {
|
||
"adv_name": "Somebody",
|
||
"public_key": "abcdef" + "00" * 29,
|
||
"out_path": "",
|
||
"out_path_len": -1,
|
||
"out_path_hash_mode": -1,
|
||
}
|
||
}
|
||
)
|
||
|
||
device = MeshCoreDevice(port="COM-test")
|
||
resolved = await device._trace_path_to_contact(mc, "Nobody")
|
||
assert resolved is None
|
||
|
||
|
||
async def test_contacts_payload_retries_transient_error() -> None:
|
||
"""A transient 'no event received' on contacts retrieval is retried, not fatal."""
|
||
from meshtools.core.connection import MeshCoreDevice
|
||
|
||
calls = {"n": 0}
|
||
|
||
class _Err:
|
||
payload = {"reason": "no event received during contacts retrieval"}
|
||
|
||
def is_error(self) -> bool:
|
||
return True
|
||
|
||
class _Ok:
|
||
payload = {"Repeater": {"adv_name": "Repeater", "public_key": "aabbcc" + "00" * 29}}
|
||
|
||
def is_error(self) -> bool:
|
||
return False
|
||
|
||
class _Commands:
|
||
async def get_contacts(self): # noqa: ANN202
|
||
calls["n"] += 1
|
||
return _Err() if calls["n"] < 3 else _Ok()
|
||
|
||
class _MC:
|
||
commands = _Commands()
|
||
|
||
device = MeshCoreDevice(port="COM-test")
|
||
payload = await device._contacts_payload(_MC(), retries=3, delay=0)
|
||
assert "Repeater" in payload
|
||
assert calls["n"] == 3 # failed twice, succeeded on the third attempt
|
||
|
||
|
||
async def test_contacts_payload_raises_clean_error_after_retries() -> None:
|
||
"""Persistent contacts-retrieval failure raises a clean, actionable error."""
|
||
from meshtools.core.connection import DeviceCommandError, MeshCoreDevice
|
||
|
||
class _Err:
|
||
payload = {"reason": "no event received during contacts retrieval"}
|
||
|
||
def is_error(self) -> bool:
|
||
return True
|
||
|
||
class _Commands:
|
||
async def get_contacts(self): # noqa: ANN202
|
||
return _Err()
|
||
|
||
class _MC:
|
||
commands = _Commands()
|
||
|
||
device = MeshCoreDevice(port="COM-test")
|
||
with pytest.raises(DeviceCommandError) as exc:
|
||
await device._contacts_payload(_MC(), retries=2, delay=0)
|
||
assert "no event received" in str(exc.value)
|
||
|
||
|
||
async def test_latest_trace_returns_previous_run(tmp_path: Path) -> None:
|
||
"""latest_trace rehydrates the most recent stored trace, excluding a given run."""
|
||
from meshtools.core.models import Hop
|
||
|
||
repo = Repository(tmp_path / "prev.db")
|
||
old_run = repo.start_run("trace", {"target": "Alice"})
|
||
repo.record_trace(
|
||
old_run,
|
||
TraceResult(target="Alice", success=True, hops=[Hop(0, "3d", 4.0), Hop(1, None, 1.0)]),
|
||
)
|
||
new_run = repo.start_run("trace", {"target": "Alice"})
|
||
|
||
prev = repo.latest_trace("Alice", exclude_run_id=new_run)
|
||
assert prev is not None
|
||
assert [h.node for h in prev.hops] == ["3d", None]
|
||
assert prev.edges("us")[-1].destination == "us"
|
||
assert repo.latest_trace("Nobody") is None
|
||
repo.close()
|
||
|
||
|
||
async def test_repository_round_trip(tmp_path: Path) -> None:
|
||
"""A run and its traces persist and read back."""
|
||
repo = Repository(tmp_path / "test.db")
|
||
run_id = repo.start_run("trace", {"target": "Alice", "samples": 2})
|
||
|
||
device = MockDevice()
|
||
await device.connect()
|
||
results = await trace_runner.run_traces(
|
||
device, "Alice", samples=2, cooldown_s=0, persist=lambda t: repo.record_trace(run_id, t)
|
||
)
|
||
repo.finish_run(run_id, "ok", {"count": len(results)})
|
||
|
||
runs = repo.list_runs()
|
||
assert runs[0].tool == "trace"
|
||
assert runs[0].status == "ok"
|
||
repo.close()
|