Files
MeshTerm/tests/test_topology.py
Jean-Pierre MartineauandClaude Opus 5.5 11b5274b31 Docstrings and comments of the tests are in ASD-STE100
Each test docstring states the behaviour that the test proves, in the
simple present. No assertion message, test id, or other string literal
changed. Four statements now agree with the code: the Trace highlight
stays on the last row, a menu count, and two others.

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

1018 lines
47 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""Unit tests for the observed-topology graph, path scenarios, and the path composer.
The graph takes data in and gives data out, so these tests run headless. The tests build
the evidence rows by hand, in the same form as the rows that the repository returns. The
assertions cover canonicalization, the folding of bidirectional links, the order of link
strength, the ranking of scenarios, and the step-by-step state machine of the composer.
"""
from __future__ import annotations
from datetime import timedelta
import pytest
import meshterm.ui.pathline as pathline
from meshterm.core.connection import DeviceCommandError, MockDevice
from meshterm.core.models import Contact, NeighbourInfo, Observation, utcnow
from meshterm.persistence.repository import (
NeighbourLink,
PacketPath,
Repository,
TracedPath,
)
from meshterm.services.path_probe import ProbeCandidate, probe_paths
from meshterm.services.topology import build_topology, collapse_width, render_custom_spec
from meshterm.services.trace_runner import make_node_resolver
from meshterm.ui.path_composer import AUTO_SPEC, FetchNeighbours, PathComposerScreen
from meshterm.ui.pathline import CURSOR_GLYPH, SELF_GLYPH
from meshterm.ui.tui.screen import CANCEL
US = "aaaaaaaaaaaa"
REPEATER = Contact(name="Hub", public_key="3d63c6429436" + "0" * 52, key_prefix="3d63c6429436")
FAR = Contact(name="Far", public_key="f2c24f54551e" + "0" * 52, key_prefix="f2c24f54551e")
LEAF = Contact(name="Leaf", public_key="27d4396a2967" + "0" * 52, key_prefix="27d4396a2967")
def _topo(trace_paths=(), packet_paths=(), neighbour_links=(), contacts=None): # noqa: ANN001
return build_topology(
self_id=US + "0" * 52,
contacts=list(contacts) if contacts is not None else [REPEATER, FAR, LEAF],
trace_paths=list(trace_paths),
packet_paths=list(packet_paths),
neighbour_links=list(neighbour_links),
)
def _traced(*hops, age_hours: float = 0.0) -> TracedPath:
"""A trace walk from ``(node_hash, snr)`` pairs. The final hop has no hash and is us."""
return TracedPath(when=utcnow() - timedelta(hours=age_hours), hops=list(hops))
# --- graph construction ---------------------------------------------------------
def test_trace_walk_yields_bidirectional_links_at_canonical_ids() -> None:
"""A boomerang trace folds the readings out and back onto the same undirected links."""
topo = _topo(trace_paths=[_traced(("3d", 12.5), ("f2", -5.5), ("3d", -6.5), (None, 11.75))])
# The walk crossed us↔Hub twice (first hop out, last hop home), and Hub↔Far twice too.
near = topo.link(topo.self_id, "3d63c6429436")
far = topo.link("3d63c6429436", "f2c24f54551e")
assert near is not None and near.samples == 2
assert sorted(near.snrs) == [11.75, 12.5]
assert far is not None and far.samples == 2
assert sorted(far.snrs) == [-6.5, -5.5]
def test_canonicalization_maps_prefixes_and_rejects_junk() -> None:
"""A hash of any width collapses onto a contact id. Ambiguous and non-hex values stay."""
topo = _topo()
assert topo.canonical("3d") == "3d63c6429436"
assert topo.canonical("3d63c6429436" + "0" * 20) == "3d63c6429436"
assert topo.canonical("hop0") is None # an old artefact of the simulator, not hex
ambiguous = _topo(contacts=[REPEATER, Contact(name="Twin", public_key="3d99" + "0" * 60)])
assert ambiguous.canonical("3d") == "3d" # two contacts match, so keep the short id
def test_coalesce_folds_a_short_hop_into_its_only_wide_match() -> None:
"""A bare hop and the wide id of the same node collapse to one node, with pooled evidence."""
twin = Contact(name="Twin", public_key="27e4" + "0" * 60) # makes a bare "27" ambiguous
walks = [
_traced(("27", 5.0), ("f2", -3.0), ("27", -3.5), (None, 5.0)), # us→27→Far
_traced(("27d4", 7.0), ("f2", -4.0), ("27d4", -4.5), (None, 7.0)), # us→27d4→Far
]
topo = _topo(trace_paths=walks, contacts=[REPEATER, FAR, LEAF, twin])
ids = {node for link in topo.links() for node in (link.a, link.b)}
assert "27" not in ids # the stub of 1 byte folded onto its only wide match in the graph
assert "27d4396a2967" in ids
link = topo.link(topo.self_id, "27d4396a2967")
assert link is not None and link.samples == 4 # the readings out and back of both walks, pooled
def test_coalesce_leaves_a_genuinely_ambiguous_stub_alone() -> None:
"""A short hop that opens two nodes in the graph is not guessed onto one of them."""
twin = Contact(name="Twin", public_key="27e41e2d7cb5" + "0" * 52, key_prefix="27e41e2d7cb5")
walks = [ # both 27d4… and 27e4… are in the graph, and also a bare 27 that cannot be decided
_traced(("27d4", 5.0), (None, 5.0)),
_traced(("27e4", 5.0), (None, 5.0)),
_traced(("27", 5.0), (None, 5.0)),
]
topo = _topo(trace_paths=walks, contacts=[REPEATER, FAR, LEAF, twin])
ids = {node for link in topo.links() for node in (link.a, link.b)}
assert {"27", "27d4396a2967", "27e41e2d7cb5"} <= ids # the stub stays a separate node
def test_coalesce_folds_an_ambiguous_stub_its_neighbourhood_elects() -> None:
"""The neighbours of a stub that the hash cannot place decide where it goes.
``3d`` opens both ``3d63…`` and ``3d99…``, so the prefix alone is a random choice. But
the stub has links to Far and Leaf, and only ``3d63…`` is heard with either of them.
There are two votes that discriminate and none against. Thus the stub folds and its
evidence is pooled.
"""
twin = Contact(name="Twin", public_key="3d99" + "0" * 60)
walks = [
_traced(("3d63", 5.0), ("f2", 5.0), ("3d63", 5.0), (None, 5.0)),
_traced(("3d63", 5.0), ("27d4", 5.0), ("3d63", 5.0), (None, 5.0)),
_traced(("3d99", 5.0), (None, 5.0)), # the rival is in the graph, but not out here
_traced(("3d", 5.0), ("f2", 5.0), ("3d", 5.0), (None, 5.0)),
_traced(("3d", 5.0), ("27d4", 5.0), ("3d", 5.0), (None, 5.0)),
]
topo = _topo(trace_paths=walks, contacts=[REPEATER, FAR, LEAF, twin])
ids = {node for link in topo.links() for node in (link.a, link.b)}
assert "3d" not in ids # elected onto the node that its neighbours agree it must be
assert {"3d63c6429436", "3d9900000000"} <= ids # the rival is unchanged
link = topo.link(topo.self_id, "3d63c6429436")
assert link is not None and link.samples == 8 # the readings of the stub came with it
def test_coalesce_leaves_a_contested_stub_standing() -> None:
"""If the neighbours point both ways, the hash pools two nodes, so the stub stays."""
twin = Contact(name="Twin", public_key="3d99" + "0" * 60)
edge = Contact(name="Edge", public_key="b1" + "0" * 62)
walks = [
_traced(("3d63", 5.0), ("f2", 5.0), ("3d63", 5.0), (None, 5.0)),
_traced(("3d63", 5.0), ("27d4", 5.0), ("3d63", 5.0), (None, 5.0)),
_traced(("3d99", 5.0), ("b1", 5.0), ("3d99", 5.0), (None, 5.0)),
_traced(("3d", 5.0), ("f2", 5.0), ("3d", 5.0), (None, 5.0)),
_traced(("3d", 5.0), ("27d4", 5.0), ("3d", 5.0), (None, 5.0)),
_traced(("3d", 5.0), ("b1", 5.0), ("3d", 5.0), (None, 5.0)),
]
topo = _topo(trace_paths=walks, contacts=[REPEATER, FAR, LEAF, twin, edge])
ids = {node for link in topo.links() for node in (link.a, link.b)}
# 2 votes to 1 is a plurality, not a verdict. The margin keeps the stub a separate node.
assert {"3d", "3d63c6429436", "3d9900000000"} <= ids
def test_coalesce_ignores_neighbours_both_candidates_share() -> None:
"""A shared neighbour is local density, not evidence. Only neighbours that discriminate vote."""
twin = Contact(name="Twin", public_key="3d99" + "0" * 60)
walks = [
_traced(("3d63", 5.0), ("f2", 5.0), ("3d63", 5.0), (None, 5.0)),
_traced(("3d99", 5.0), ("f2", 5.0), ("3d99", 5.0), (None, 5.0)),
_traced(("3d63", 5.0), ("27d4", 5.0), ("3d63", 5.0), (None, 5.0)),
_traced(("3d99", 5.0), ("27d4", 5.0), ("3d99", 5.0), (None, 5.0)),
_traced(("3d", 5.0), ("f2", 5.0), ("3d", 5.0), (None, 5.0)),
_traced(("3d", 5.0), ("27d4", 5.0), ("3d", 5.0), (None, 5.0)),
]
topo = _topo(trace_paths=walks, contacts=[REPEATER, FAR, LEAF, twin])
ids = {node for link in topo.links() for node in (link.a, link.b)}
# Each neighbour of the stub is a neighbour of both candidates, so nothing can show which
# is which. Thus the code does not guess that the stub is the busier candidate.
assert {"3d", "3d63c6429436", "3d9900000000"} <= ids
def test_contact_routes_and_packet_paths_feed_the_graph() -> None:
"""Routes that the firmware learned and packet paths from the RX log are both evidence."""
routed = Contact(
name="Far",
public_key=FAR.public_key,
key_prefix=FAR.key_prefix,
route_hops=("3d63c6429436",),
last_seen=utcnow(),
)
packet = PacketPath(when=utcnow(), origin="27d4396a2967", snr=8.0, hops=["3d63c6429436"])
topo = _topo(packet_paths=[packet], contacts=[REPEATER, routed, LEAF])
assert topo.link(topo.self_id, "3d63c6429436") is not None # both sources touch it
route_link = topo.link("3d63c6429436", "f2c24f54551e")
assert route_link is not None and route_link.sources == {"route"}
packet_link = topo.link("27d4396a2967", "3d63c6429436")
assert packet_link is not None and packet_link.sources == {"packet"}
# Only the final link (last relay → us) has the reception SNR.
assert topo.link(topo.self_id, "3d63c6429436").snrs == [8.0]
assert packet_link.snrs == []
def test_neighbour_reports_feed_the_graph_as_fetched_evidence() -> None:
"""A table from a repeater adds links at its vantage point, tagged ``neighbour``."""
reported = [
NeighbourLink(when=utcnow(), repeater="3d63c6429436", neighbour="f2c2", snr=7.0),
NeighbourLink(when=utcnow(), repeater="3d63c6429436", neighbour="beefbeef", snr=None),
]
topo = _topo(neighbour_links=reported)
link = topo.link("3d63c6429436", "f2c24f54551e") # 'f2c2' canonicalizes onto Far
assert link is not None and link.sources == {"neighbour"}
assert link.snrs == [7.0] # the SNR that the repeater measured is on the link
# A neighbour that no contact matches still counts. It is a discovery of a new node.
unknown = topo.link("3d63c6429436", "beefbeef")
assert unknown is not None and unknown.snrs == []
suggested = [s.node for s in topo.next_hops("3d63c6429436")]
assert "f2c24f54551e" in suggested and "beefbeef" in suggested
# --- suggestions and scenarios ----------------------------------------------------
def test_next_hops_sorts_by_link_strength_and_excludes_used_nodes() -> None:
"""The suggestions rank the fresh link with many observations first, and keep the exclusions."""
strong = [_traced(("3d", 12.0), (None, 12.0)) for _ in range(6)]
weak = [_traced(("27d4", -9.0), (None, -9.0), age_hours=24 * 21)]
topo = _topo(trace_paths=strong + weak)
nodes = [s.node for s in topo.next_hops(topo.self_id)]
assert nodes == ["3d63c6429436", "27d4396a2967"]
excluded = topo.next_hops(topo.self_id, exclude=frozenset({"3d63c6429436"}))
assert [s.node for s in excluded] == ["27d4396a2967"]
def test_scenarios_rank_observed_route_and_pin_device_route_first() -> None:
"""The device route is first, then the observed evidence, then the direct try (unobserved)."""
walks = [_traced(("3d", 12.0), ("f2", -5.0), ("3d", -5.5), (None, 12.0)) for _ in range(4)]
topo = _topo(trace_paths=walks)
scenarios = topo.scenarios("f2c24f54551e", device_route=("3d63c6429436",))
assert scenarios[0].source == "device"
assert scenarios[0].hops == ("3d63c6429436",)
labels = [s.source for s in scenarios]
assert "direct" in labels # the baseline is always offered
direct = next(s for s in scenarios if s.source == "direct")
assert direct.score == 0.0 # never observed, so it ranks on measurement only
# The observed route (the same hops as the device route) is removed as a duplicate.
assert sum(1 for s in scenarios if s.hops == ("3d63c6429436",)) == 1
def test_suggested_returns_the_strongest_observed_route_only() -> None:
"""suggested() is the answer of the data: the best observed multi-hop route to the target."""
walks = [_traced(("3d", 12.0), ("f2", -5.0), ("3d", -5.5), (None, 12.0)) for _ in range(4)]
topo = _topo(trace_paths=walks)
best = topo.suggested("f2c24f54551e")
assert best is not None
assert best.source == "observed" # never the device or direct families
assert best.hops == ("3d63c6429436",) # us → Hub → Far
assert best.score > 0 and best.samples >= 4 # supported by evidence
def test_suggested_is_none_without_an_observed_repeater_route() -> None:
"""A target that was reached only directly (or not at all) has no route to suggest."""
# One direct trace to the Hub: us → Hub → us, with no repeater between.
topo = _topo(trace_paths=[_traced(("3d", 9.0), (None, 9.0))])
assert topo.suggested("3d63c6429436") is None # direct only: nothing to suggest
assert topo.suggested("f2c24f54551e") is None # never reached at all
def test_scenarios_return_disjoint_alternatives_cheapest_first() -> None:
"""Two independent routes to a target both appear, ranked by strength of evidence."""
# Two repeaters, each a separate route of one hop to Far. The link of Hub is stronger.
walks = [_traced(("3d", 12.0), ("f2", -3.0), ("3d", -3.0), (None, 12.0)) for _ in range(3)] + [
_traced(("27", 4.0), ("f2", -8.0), ("27", -8.0), (None, 4.0)) for _ in range(3)
]
topo = _topo(trace_paths=walks)
observed = [s for s in topo.scenarios("f2c24f54551e") if s.source == "observed" and s.hops]
assert [s.hops for s in observed[:2]] == [("3d63c6429436",), ("27d4396a2967",)]
assert observed[0].score > observed[1].score # the stronger route ranks first
def test_best_routes_stays_fast_on_a_densely_connected_core() -> None:
"""A well-heard core must not stop the route search. This search avoids that fault.
A best-first walk over partial paths floods a dense core. Each prefix that wanders
through the core is cheaper than the one weak link that a distant target is behind. Thus
the frontier grows to millions of dead-end prefixes (tens of seconds for a busy node).
The k-shortest search of Yen stays polynomial. Here a clique of 20 nodes has the target
on a single weak link. The search takes milliseconds (the old frontier took seconds), and
it still finds the route out.
"""
import time
now = utcnow()
core = [f"c0de{i:08x}" for i in range(20)] # 20 distinct 12-hex core nodes
target = "fa11faceface"
links = []
us = US + "0" * 52
# Each node in the core hears each other node (a full clique), and hears us. This is a
# frontier as dense as possible, where the search can get lost.
for i, a in enumerate([us, *core]):
for b in [*core][i:]:
if a != b:
links.append(NeighbourLink(when=now, repeater=a, neighbour=b, snr=8.0))
# The target is reachable only across one weak link from the last node of the core.
links.append(NeighbourLink(when=now, repeater=core[-1], neighbour=target, snr=-12.0))
topo = _topo(neighbour_links=links)
started = time.perf_counter()
scenarios = topo.scenarios(target)
elapsed = time.perf_counter() - started
assert elapsed < 1.0 # milliseconds in practice. The old frontier took seconds or more
observed = [s for s in scenarios if s.source == "observed" and s.hops]
assert observed # the route through the core to the target was found
assert observed[0].hops[-1] == core[-1] # it arrives through the one weak link
def test_scenario_spec_ends_at_target_and_collapses_width() -> None:
"""A spec goes out to the target, then mirrors the hops back, at one width."""
walks = [_traced(("3d", 12.0), ("f2", -5.0), ("3d", -5.5), (None, 12.0))]
topo = _topo(trace_paths=walks)
scenario = next(s for s in topo.scenarios("f2c24f54551e") if s.hops == ("3d63c6429436",))
assert scenario.spec("f2c24f54551e", 2) == "3d63,f2c2,3d63"
assert collapse_width("3d", "f2c24f54551e", ceiling=2) == 1 # the narrowest hash decides
def test_render_custom_spec_is_verbatim_at_a_uniform_width() -> None:
"""An asymmetric walk renders exactly its hops, with no target added and no mirror."""
hops = ("3d63c6429436", "f2c24f54551e", "27d4396a2967")
assert render_custom_spec(hops, 2) == "3d63,f2c2,27d4"
# A hop that is known only narrowly makes the whole spec as narrow as that hop.
assert render_custom_spec(("3d63c6429436", "be"), 2) == "3d,be"
assert render_custom_spec((), 2) == "" # no hops, no spec (never a false "auto")
# --- repository round-trip ---------------------------------------------------------
def test_repository_round_trips_packet_paths_and_candidates(tmp_path) -> None: # noqa: ANN001
"""The repository stores the path of a packet observation. Candidates go to path_candidates."""
repo = Repository(tmp_path / "t.db")
run = repo.start_run("monitor", {})
repo.record_observation(
run, Observation(node="27d4396a2967", kind="packet", snr=7.5, path="3d63,f2c2")
)
repo.record_observation(run, Observation(node="27d4396a2967", kind="advert", snr=3.0))
packets = repo.packet_paths()
assert len(packets) == 1
assert packets[0].hops == ["3d63", "f2c2"]
assert packets[0].origin == "27d4396a2967"
# Packet rows never change the reception statistics of a node.
heard = repo.heard_nodes()
assert len(heard) == 1 and heard[0].count == 1 and heard[0].median_snr == 3.0
probe_run = repo.start_run("trace", {"mode": "probe"})
repo.record_path_candidate(
probe_run, "Far", "3d,f2", bottleneck_snr=-5.0, success_rate=1.0, median_rtt_ms=320.0
)
row = repo._conn.execute("SELECT * FROM path_candidates").fetchone()
assert row["target"] == "Far" and row["success_rate"] == 1.0
repo.close()
def test_repository_neighbour_snapshots_keep_latest_per_pair(tmp_path) -> None: # noqa: ANN001
"""A table that MeshTerm gets again replaces the old snapshot. It does not stack as evidence."""
repo = Repository(tmp_path / "n.db")
run = repo.start_run("trace", {"mode": "neighbours"})
heard = utcnow() - timedelta(hours=2)
repo.record_neighbours(
run,
"3d63c6429436",
[
NeighbourInfo(node="f2c24f54", snr=-5.0, heard_at=heard),
NeighbourInfo(node="beefbeef", snr=2.0, heard_at=None),
],
)
links = {link.neighbour: link for link in repo.neighbour_links()}
assert len(links) == 2
assert links["f2c24f54"].snr == -5.0
assert links["f2c24f54"].when == heard # the recency from the repeater remains
assert links["beefbeef"].when is not None # no heard_at, so it uses fetched_at
repo.record_neighbours(run, "3d63c6429436", [NeighbourInfo(node="f2c24f54", snr=9.0)])
links = {link.neighbour: link for link in repo.neighbour_links()}
assert len(links) == 2 # still one link per pair
assert links["f2c24f54"].snr == 9.0 # the new snapshot replaced the old one
repo.close()
async def test_mock_fetch_neighbours_is_login_gated_like_real_firmware() -> None:
"""Without a login, the mock ignores the request (an error). After a login, it gives the table.
The table includes a node that was not heard before.
"""
device = MockDevice()
await device.connect()
contacts = await device.get_contacts()
yagi = next(c for c in contacts if c.name == "Yagi-Repeater")
with pytest.raises(DeviceCommandError):
await device.fetch_neighbours(yagi) # the same as v1.15 firmware: it ignores guests
assert await device.admin_login(yagi, "admin")
entries = await device.fetch_neighbours(yagi)
nodes = {e.node for e in entries}
assert "e5f6a7b8" in nodes # a node that is not in the contact list: a discovery
assert all(e.heard_at is not None for e in entries)
alice = next(c for c in contacts if c.name == "Alice")
assert await device.admin_login(alice, "admin")
with pytest.raises(DeviceCommandError):
await device.fetch_neighbours(alice) # a chat node has no neighbour table
# --- path probe ----------------------------------------------------------------------
async def test_probe_paths_ranks_reliability_first() -> None:
"""A path that always answers ranks above a path with a stronger SNR that loses traces."""
from meshterm.core.models import Hop, TraceResult
class _Device:
async def run_trace(self, target, *, path=None, timeout=10.0): # noqa: ANN001
if path == "good":
return TraceResult(
target=target,
success=True,
hops=[Hop(index=0, node="3d", snr=2.0)],
round_trip_ms=300.0,
)
return TraceResult(target=target, success=False)
outcomes = await probe_paths(
_Device(),
"Far",
[ProbeCandidate(label="flaky", spec="bad"), ProbeCandidate(label="solid", spec="good")],
samples=2,
cooldown_s=0.0,
)
assert [o.candidate.label for o in outcomes] == ["solid", "flaky"]
assert outcomes[0].stats.success_rate == 1.0
async def test_probe_paths_defaults_to_one_trace_per_candidate() -> None:
"""By default, MeshTerm measures each candidate with a single transmission.
This is the rule that avoids the blacklist. Repeaters penalize nodes that send bursts of
traffic. Thus a probe of N paths must cost exactly N traces, unless the caller chooses more.
"""
from meshterm.core.models import Hop, TraceResult
transmitted: list[str] = []
class _Device:
async def run_trace(self, target, *, path=None, timeout=10.0): # noqa: ANN001
transmitted.append(path)
return TraceResult(
target=target,
success=True,
hops=[Hop(index=0, node="3d", snr=2.0)],
round_trip_ms=300.0,
)
outcomes = await probe_paths(
_Device(),
"Far",
[ProbeCandidate(label="a", spec="3d"), ProbeCandidate(label="b", spec="f2")],
cooldown_s=0.0,
)
assert transmitted == ["3d", "f2"] # one trace per candidate, in order
assert all(o.stats.samples == 1 for o in outcomes)
# --- path composer ---------------------------------------------------------------------
def _composer( # noqa: ANN001
topo, hops=None, fetch_nodes=frozenset(), target=True, cursor=None, resolve=None
):
"""A composer over ``topo``, pinned on Far (target mode) or with no target."""
pinned = (
dict(
target_id="f2c24f54551e",
target_hash="f2c24f54551e" + "0" * 52,
target_label="Far",
)
if target
else {}
)
screen = PathComposerScreen(
device_label="Us",
device_hash=US + "0" * 52,
topology=topo,
width_bytes=1,
hops=list(hops or []),
cursor=cursor,
fetch_nodes=fetch_nodes,
**({"resolve": resolve} if resolve is not None else {}),
**pinned,
)
screen.note_viewport(30) # the frame records the budget of the dialog before each paint
return screen
def _rows_plain(screen: PathComposerScreen) -> str:
return "\n".join(screen.render_body(90))
def test_composer_suggests_from_tail_and_appends_on_enter() -> None:
"""The strongest neighbour of the tail of the path is the first suggestion. Enter adds it."""
walks = [_traced(("3d", 12.0), ("f2", -5.0), ("3d", -5.5), (None, 12.0))]
screen = _composer(_topo(trace_paths=walks))
body = _rows_plain(screen)
assert "Hub" in body and "auto" in body.lower() # the suggestion and the "Auto" action row
screen.handle("enter") # top row: the Hub suggestion
assert screen._hops == ["3d63c6429436"]
# The target is excluded from the new tail, so Far never appears as a hop.
assert all(s.node != "f2c24f54551e" for s in screen._suggestions())
def test_composer_windows_rows_under_the_pinned_route_preview() -> None:
"""A short dialog shows a window of the rows. The route preview and highlight stay visible."""
import re
walks = [_traced((f"{i + 16:02x}", 3.0), (None, 3.0)) for i in range(10)]
screen = _composer(_topo(trace_paths=walks, contacts=[FAR]))
screen.note_viewport(9) # a short budget for the dialog: preview, heading, and a few rows
body = re.sub(r"\x1b\[[0-9;]*m", "", "\n".join(screen.render_body(90)))
assert SELF_GLYPH in body # the route preview is pinned and does not scroll out
assert "↓" in body and "more" in body # the edge shows a count of the hidden rows
screen.handle("end") # highlight to the last action row, and the window follows
body = re.sub(r"\x1b\[[0-9;]*m", "", "\n".join(screen.render_body(90)))
assert "Auto" in body and "↑" in body
def test_composer_typed_hex_adds_a_custom_hop_and_backspace_removes() -> None:
"""Hex of even length that the user types in the filter becomes a custom hop to add."""
screen = _composer(_topo())
for ch in "beef":
screen.handle("text", ch)
screen.handle("enter") # the "+ add hop beef" row is first
assert screen._hops == ["beef"]
screen.handle("backspace") # the entry is empty, so it removes the last hop
assert screen._hops == []
def test_composer_cursor_inserts_and_deletes_mid_path() -> None:
"""The ←/→ keys move the insertion slot. An add puts a hop at the slot.
The ⌫ key removes the hop to the left of the slot.
"""
screen = _composer(_topo(), hops=["27d4396a2967", "f2c24f54551e"], target=False)
assert screen.cursor == 2 # opens on the last arrow, so an insert is an append
screen.handle("right")
assert screen.cursor == 2 # clamped at the end…
screen.handle("left")
screen.handle("left")
screen.handle("left")
assert screen.cursor == 0 # …and at home
for ch in "beef": # insert at home: the hop goes before the hops that were set first
screen.handle("text", ch)
screen.handle("enter")
assert screen._hops == ["beef", "27d4396a2967", "f2c24f54551e"]
assert screen.cursor == 1 # the slot moved past the hop that it inserted
screen.handle("backspace") # ⌫ removes the hop to the left of the slot, and the slot follows
assert screen._hops == ["27d4396a2967", "f2c24f54551e"] and screen.cursor == 0
# A reopen (the round trip to fetch neighbours) starts at the position that was set.
assert _composer(_topo(), hops=["27d4396a2967"], cursor=1).cursor == 1
assert _composer(_topo(), hops=["27d4396a2967"], cursor=99).cursor == 1 # clamped
def test_composer_suggestions_follow_the_cursor_anchor() -> None:
"""The heading and the list move to the node at the left of the insertion slot when it moves."""
import re
walks = [_traced(("3d", 12.0), ("f2", -5.0), ("3d", -5.5), (None, 12.0))]
screen = _composer(_topo(trace_paths=walks), hops=["3d63c6429436"], target=False)
def body() -> str:
return re.sub(r"\x1b\[[0-9;]*m", "", "\n".join(screen.render_body(90)))
assert "Next hop from Hub" in body() # the slot is at the end, so the anchor is the tail
screen.handle("left")
assert "Next hop from Us" in body()
# An insert of the right neighbour of the slot gives a self-loop, so Hub is not proposed.
assert all(s.node != "3d63c6429436" for s in screen._suggestions())
def test_composer_preview_stands_the_cursor_in_the_route() -> None:
"""The composer preview shows the insertion slot in the route itself.
The insertion point is a hop: the ``+`` slot that the next node takes. The ←/→ keys
move it, one position at a time.
"""
screen = _composer(_topo(), hops=["3d63c6429436"], target=False)
def slot_at() -> int:
labels = [hop.label for hop in screen._route_preview().hops]
assert labels.count(CURSOR_GLYPH) == 1 # always exactly one slot
return labels.index(CURSOR_GLYPH)
assert slot_at() == 2 # opens after us and the composed hop, so an insert is an append
screen.handle("left")
assert slot_at() == 1 # moved home, before the hop that it now precedes
def test_composer_warns_when_the_walk_repeats_a_link() -> None:
"""A walk over a link twice in one direction shows the yellow not-a-trail note.
A correction of the path removes the note.
"""
clean = _composer(_topo(), hops=["3d63c6429436", "f2c24f54551e"], target=False)
assert "not a trail" not in _rows_plain(clean)
screen = _composer(
_topo(),
hops=["3d63c6429436", "f2c24f54551e"] * 2, # …→ 3d → f2 again
target=False,
)
body = _rows_plain(screen)
assert "⚠" in body and "not a trail" in body and "records ignore" in body
screen.handle("backspace") # remove the second f2, and the repeat is gone
assert "not a trail" not in _rows_plain(screen)
def test_composer_dialog_only_ever_grows() -> None:
"""The box ratchets in both dimensions, so it does not jump when the path changes."""
screen = _composer(_topo(), hops=[], target=False)
assert screen.grow_only is True
# ratchet_viewport and ratchet_width are the grow-only contract: they rise and never drop.
assert screen.ratchet_viewport(6) == 6
assert screen.ratchet_viewport(14) == 14 # a longer suggestion list makes the box bigger
assert screen.ratchet_viewport(4) == 14 # a shorter list after it keeps the taller box
assert screen.ratchet_width(40) == 40
assert screen.ratchet_width(64) == 64 # a longer route preview makes the box wider
assert screen.ratchet_width(40) == 64 # narrower content after it keeps the wider box
def test_composer_target_mode_warning_covers_the_mirrored_return() -> None:
"""The check runs over the whole boomerang. A repeated outbound section starts the warning."""
clean = _composer(_topo(), hops=["3d63c6429436"])
assert "not a trail" not in _rows_plain(clean) # the plain boomerang is a trail
screen = _composer(
_topo(),
hops=["3d63c6429436", "27d4396a2967", "3d63c6429436", "27d4396a2967"],
)
assert "not a trail" in _rows_plain(screen)
async def test_composer_fetch_row_resolves_a_fetch_request() -> None:
"""On a repeater that MeshTerm can fetch from, Enter on the fetch row sends a request."""
import asyncio
screen = _composer(_topo(), hops=["3d63c6429436"], fetch_nodes=frozenset({"3d63c6429436"}))
screen.future = asyncio.get_running_loop().create_future()
body = _rows_plain(screen)
assert "Fetch neighbours from" in body and "Hub" in body
screen.handle("enter") # an empty graph has no suggestions, so fetch is the top row
result = screen.future.result()
assert isinstance(result, FetchNeighbours) and result.node == "3d63c6429436"
assert screen.hops == ["3d63c6429436"] # kept, so the owner can reopen it mid-path
def test_composer_hides_fetch_row_off_fetchable_tails() -> None:
"""The fetch row follows the tail of the path. At our own node there is nothing to ask."""
screen = _composer(_topo(), fetch_nodes=frozenset({"3d63c6429436"}))
assert "Fetch neighbours" not in _rows_plain(screen) # the tail is us, not the repeater
def test_composer_use_row_names_the_action_not_the_path() -> None:
"""The commit row does not write the route again. The preview two rows up is the route.
A repeat as raw hex (JP, 2026-08-10) said the same thing in a worse way, and the row
grew by one hop each time the path grew. Only the unarmed case needs words: a row that
cannot commit must say why.
"""
from tests.conftest import plain
walks = [_traced(("3d", 12.0), (None, 12.0))]
def use_row(screen: PathComposerScreen) -> str:
body = plain(screen.render_body(90))
row = next(ln for ln in body.splitlines() if "Use this path" in ln)
return row.replace("❯", "").strip()
armed = _composer(_topo(trace_paths=walks), hops=["3d63c6429436"])
assert use_row(armed) == "✓ Use this path"
assert armed._spec() == "3d,f2,3d" # …the spec is still what Enter commits
# An unarmed row must still say why it cannot commit.
empty = _composer(_topo(trace_paths=walks), hops=[])
empty._spec = lambda: "" # type: ignore[method-assign]
assert use_row(empty) == "✓ Use this path (add a hop first)"
async def test_composer_commits_spec_auto_and_cancel() -> None:
"""Use resolves the spec with its mirrored return leg. Auto resolves empty. Esc cancels.
Only the Esc key leaves. The action group ends at Auto, so End goes to the last row that
does something. It does not go to a row that only presses Esc for the user.
"""
import asyncio
walks = [_traced(("3d", 12.0), (None, 12.0))]
screen = _composer(_topo(trace_paths=walks), hops=["3d63c6429436"])
screen.future = asyncio.get_running_loop().create_future()
screen.handle("end") # jump to the last action row (Auto)…
screen.handle("up") # …up to "Use this path"
screen.handle("enter")
assert screen.future.result() == "3d,f2,3d"
auto = _composer(_topo(trace_paths=walks))
auto.future = asyncio.get_running_loop().create_future()
auto.handle("end") # "Auto — let the device route" closes the group
auto.handle("enter")
assert auto.future.result() == AUTO_SPEC
cancelled = _composer(_topo(trace_paths=walks))
cancelled.future = asyncio.get_running_loop().create_future()
cancelled.handle("escape")
assert cancelled.future.result() is CANCEL
def test_composer_path_mode_opens_star_to_star_without_auto(monkeypatch) -> None: # noqa: ANN001
"""With no target, the preview is only ``★ → + → ★`` and there is no Auto action.
Both ends are our own node, bare and faded. A walk always leaves us and comes home to
us, and the user cannot compose or remove an end. The automatic grey shows this. The
empty slot is between the ends. With no destination, the device has nothing to route to.
"""
monkeypatch.setattr(pathline, "powerline_enabled", lambda: False) # assert the words
screen = _composer(_topo(), target=False)
assert screen.title == "Compose path" # no target to name
preview = screen._route_preview().text()
# The preview has no name and no hash of ours. The slot shows where the first hop goes.
assert preview.plain == f"{SELF_GLYPH} → {CURSOR_GLYPH} → {SELF_GLYPH}"
stars = [
str(span.style)
for span in preview.spans
if preview.plain[span.start : span.end] == SELF_GLYPH
]
assert stars == ["faint", "faint"] # read-only, like each chip that the screen manages
assert "Auto" not in _rows_plain(screen)
def test_composer_names_an_ambiguous_hop_through_the_owning_screens_resolver() -> None:
"""A short hop that the topology does not name still shows a name, with its hash beside it.
A hop of 1 byte that matches the prefix of two contacts is ambiguous. The topology does
not guess, and it keeps the bare hash. Thus the suggestion list proposed unclear hex for
nodes that the trace screen behind it named without a problem. The resolver of the
owning screen is the fallback, so both surfaces show the same thing.
"""
twin = Contact(name="Twin", public_key="3d99" + "0" * 60) # makes a bare "3d" ambiguous
topo = _topo(trace_paths=[_traced(("3d", 12.0), (None, 12.0))], contacts=[REPEATER, twin])
assert topo.display_name("3d") is None # two contacts match, so the graph does not choose
import re
blind = _composer(topo, target=False)
assert "Hub" not in _rows_plain(blind) # without a fallback: bare hex, as before
named = _composer(topo, target=False, resolve=lambda h: "Hub" if h == "3d" else h)
body = re.sub(r"\x1b\[[0-9;]*m", "", _rows_plain(named))
assert "Hub (3d)" in body # the name, and the hash that addresses it beside the name
def test_composer_merges_a_stub_and_its_full_id_into_one_suggestion() -> None:
"""One node that the graph holds twice is one row, in its text and in its address.
``3d`` matches the prefix of two contacts, so the topology keeps it as a separate vertex
and does not guess. It is separate from the ``3d63c6429436`` where its wider sightings
went. When both have a name, the list proposes the same node twice. Thus they fold
onto the longer id, and the evidence is pooled. The twin with the ``3d`` prefix has a
different name, so it stays a separate row. A shared prefix alone never merges two nodes.
"""
twin = Contact(name="Twin", public_key="3d99" + "0" * 60, key_prefix="3d9900000000")
walks = [
_traced(("3d", 6.0), (None, 6.0)), # the ambiguous stub
_traced(("3d63c6429436", 8.0), (None, 8.0)), # the same node, with its full id
_traced(("3d9900000000", 4.0), (None, 4.0)), # a different node behind 3d
]
topo = _topo(trace_paths=walks, contacts=[REPEATER, twin])
assert topo.display_name("3d") is None # ambiguous: the graph does not fold it itself
assert {s.node for s in topo.next_hops(topo.self_id)} == {
"3d",
"3d63c6429436",
"3d9900000000",
}
screen = _composer(topo, target=False, resolve=make_node_resolver([REPEATER, twin]))
rows = screen._suggestions()
assert [r.node for r in rows] == ["3d63c6429436", "3d9900000000"]
assert rows[0].link.samples == 4 # the readings out and back of both walks, pooled
assert rows[0].link.median_snr == 7.0 # 6.0 and 8.0 twice each
def test_composer_steps_off_a_merged_node_with_all_its_evidence() -> None:
"""From the merged node, the composer sees all that was heard by either of its ids.
When the stub folds away, its links must not fold away with it. If they fold away, the
merge silently removes from the composer all that was observed only under the stub.
"""
twin = Contact(name="Twin", public_key="3d99" + "0" * 60, key_prefix="3d9900000000")
walks = [
_traced(("3d", 6.0), ("f2", -5.0), ("3d", -5.5), (None, 6.0)), # Far, through the stub
_traced(("3d63c6429436", 8.0), (None, 8.0)), # the full id
_traced(("3d9900000000", 4.0), (None, 4.0)), # the other 3d node
]
topo = _topo(trace_paths=walks, contacts=[REPEATER, FAR, twin])
screen = _composer(
topo, target=False, hops=["3d63c6429436"], resolve=make_node_resolver([REPEATER, FAR, twin])
)
# Far was heard only through the stub, and the screen still offers it from the full id.
assert [s.node for s in screen._suggestions()] == ["f2c24f54551e"]
async def test_composer_path_mode_suggests_through_anything_and_commits_verbatim() -> None:
"""A path walk routes through any node and commits exactly the hops that the user composed."""
import asyncio
walks = [_traced(("3d", 12.0), ("f2", -5.0), ("3d", -5.5), (None, 12.0))]
screen = _composer(_topo(trace_paths=walks), hops=["3d63c6429436"], target=False)
# There is no pinned target. The tail (Hub) hears Far, so Far is a plain hop suggestion.
assert any(s.node == "f2c24f54551e" for s in screen._suggestions())
screen.handle("enter") # the only suggestion: Far joins the walk
# A return leg can use an outbound repeater again (only the tail is not permitted).
assert any(s.node == "3d63c6429436" for s in screen._suggestions())
# Out through Hub, home directly from Far: nothing is appended or mirrored.
assert screen._spec() == "3d,f2"
loop = asyncio.get_running_loop()
screen.future = loop.create_future()
screen.handle("end") # "Use this path": path mode ends the group there, with no Auto row
screen.handle("enter")
assert screen.future.result() == "3d,f2"
# With no hops, there is nothing to commit. Use must do nothing, because a resolve of ""
# looks like Auto (routed by the device).
empty = _composer(_topo(trace_paths=walks), target=False)
empty.future = loop.create_future()
assert "(add a hop first)" in _rows_plain(empty)
empty.handle("end")
empty.handle("up")
empty.handle("enter")
assert not empty.future.done()
# --- the prefix settle is a scan of a sorted range, not a sweep of the graph ---------
def test_extensions_matches_a_naive_scan_over_awkward_shapes() -> None:
"""The range that a binary search finds must be the same as the range from a scan of each node.
``_extensions`` answers the question "which ids strictly extend this short one". It
does a binary search of a list in lexicographic order, because its caller asks once for
each node, and the caller of that caller restarts the whole pass after each merge. On a
real mesh, the scan was approximately 24 million ``len`` calls and sixteen seconds. The
shapes below are the shapes that a graph of hex prefixes produces: chains, shared stems,
a full-width id, and near-misses on each side of the run that a loose range includes.
"""
from meshterm.services.topology import MeshTopology
nodes = {
"6",
"65",
"6532",
"6532eb",
"6532eb00aa11",
"6533",
"653300ff",
"66",
"6600",
"a1",
"a1b2",
"a1b3",
"b0",
"ffffffffffff",
}
ordered = sorted(nodes)
for short in sorted(nodes):
naive = sorted(
other
for other in nodes
if other != short and len(other) > len(short) and other.startswith(short)
)
assert sorted(MeshTopology._extensions(short, ordered)) == naive, short
def test_extensions_ignores_a_full_width_id() -> None:
"""An id of twelve hex digits is canonical. It is never the prefix of another id."""
from meshterm.services.topology import MeshTopology
ordered = sorted({"6532eb00aa11", "6532eb00aa1122", "65"})
assert MeshTopology._extensions("6532eb00aa11", ordered) == []
def test_prefix_settle_folds_a_chain_but_not_a_fork() -> None:
"""The end-to-end behaviour that the fast lookup must keep.
``65`` -> ``6532`` -> ``6532eb...`` is one node with three names, so the graph holds it
once at the end. If a sibling really forks the stem, ``65`` is no longer an abbreviation.
It is then a random choice between two real nodes. It must stay as it is, with a short
name that is correct, and not be folded onto either node.
"""
from meshterm.core.models import utcnow
from meshterm.services.topology import MeshTopology
def graph(hops):
topo = MeshTopology(self_id="local", contacts=[])
now = utcnow()
for hop in hops:
topo.add_walk(["local", hop], when=now, source="trace")
topo.coalesce_prefixes()
return {end for link in topo._links for end in link}
chain = graph(("65", "6532", "6532eb00aa11"))
assert chain == {"local", "6532eb00aa11"}, f"chain not folded: {sorted(chain)}"
fork = graph(("65", "6532", "6532eb00aa11", "653300ff2211"))
assert "6532eb00aa11" in fork and "653300ff2211" in fork
assert "6532" not in fork, "an unambiguous link in the chain should still fold"
assert "65" in fork, "a stub opening two real nodes must not be guessed onto one"
def test_prefix_settle_is_repeatable_whatever_order_the_nodes_arrived_in() -> None:
"""The same evidence must always settle to the same graph.
The passes offer the ids that have too few bytes, the shortest first. For some time, the
ids of the same width came out in the order in which the node set iterated. CPython
randomizes the hash of strings in each process, so two runs over the same evidence could
do the merges in different orders. If one fold changes what a later fold can see, the
graphs are different. A feed of the same walks in reversed order causes the same problem,
because a set that was built with a different insertion sequence iterates in a different
order. The settled endpoints must not depend on the order.
"""
from meshterm.core.models import utcnow
from meshterm.services.topology import MeshTopology
walks = [
["local", "a1", "b2c3"],
["local", "a1b2", "b2c3d4"],
["local", "a1b2c3d4e5f6"],
["local", "b2c3d4e5f601"],
["local", "a1b2c3", "c4"],
["local", "c4d5e6f70011"],
["local", "b2", "a1b2c3d4e5f6"],
]
def settle(order):
topo = MeshTopology(self_id="local", contacts=[])
now = utcnow()
for walk in order:
topo.add_walk(list(walk), when=now, source="trace")
topo.coalesce_prefixes()
return sorted(topo._links)
assert settle(walks) == settle(list(reversed(walks)))
def test_a_merge_leaves_the_neighbour_table_exactly_as_a_fresh_one() -> None:
"""The settle keeps one table across each merge and does not build it again.
This is correct only while the incremental update is exact. A stale neighbour gives
bad evidence to the corroboration vote. A node that stays after its last link folded away
gives a merge candidate that no longer exists. Thus after each merge, the kept
table must be equal to a table that is derived from the changed graph.
"""
from meshterm.core.models import utcnow
from meshterm.services.topology import MeshTopology
topo = MeshTopology(self_id="local", contacts=[])
now = utcnow()
for walk in (
["local", "a1", "b2c3d4e5f601"],
["local", "a1b2c3d4e5f6"],
["a1", "c4d5e6f70011"],
["a1b2", "a1b2c3d4e5f6"],
["b2c3d4e5f601", "c4d5e6f70011"],
["local", "d7"],
["d7e8f9001122", "local"],
):
topo.add_walk(list(walk), when=now, source="trace")
adjacency = topo._adjacency()
merges = 0
while True:
shorts = sorted((n for n in adjacency if len(n) < 12), key=lambda n: (len(n), n))
ordered = sorted(adjacency)
merge = topo._next_prefix_merge(shorts, ordered) or topo._next_corroborated_merge(
shorts, ordered, adjacency
)
if merge is None:
break
topo._merge_node(*merge, adjacency=adjacency)
merges += 1
assert adjacency == topo._adjacency(), f"table drifted after merging {merge}"
assert merges, "the fixture should exercise at least one merge"
def test_a_stub_whose_only_link_was_its_owner_takes_the_owner_out_with_it() -> None:
"""The incremental table must handle this corner: both ends can leave at once.
``a1`` is heard only with ``a1b2c3d4e5f6``. There is one link, between the two names of
one node. When the stub folds, that link collapses to a self-loop and is discarded. The
owner then has no links and leaves the graph too. Neither node can stay in the node set
that the next pass reads.
"""
from meshterm.core.models import utcnow
from meshterm.services.topology import MeshTopology
topo = MeshTopology(self_id="local", contacts=[])
topo.add_walk(["a1", "a1b2c3d4e5f6"], when=utcnow(), source="trace")
adjacency = topo._adjacency()
topo._merge_node("a1", "a1b2c3d4e5f6", adjacency=adjacency)
assert topo._links == {}
assert adjacency == {}
def test_composer_row_cursor_clamps_at_both_ends() -> None:
"""The list window of the suggestions does not wrap.
The ↑ key on the first row and the ↓ key past the last row stay where they are. They do
not move the window from one end to the other.
"""
walks = [_traced(("3d", 12.0), ("f2", -5.0), ("3d", -5.5), (None, 12.0))]
screen = _composer(_topo(trace_paths=walks))
screen.render_body(90)
last = len(screen._rows()) - 1
screen.handle("up")
assert screen._index == 0
for _ in range(last + 5):
screen.handle("down")
assert screen._index == last