Show node hashes in trace tables and info panel
Annotate our own device with its hash on the route line, the per-trace From→To table, and the per-hop medians table, truncated to the command's path-hash width like every other hop. Unify link rendering on _route_node_text so all three reads consistently as name (hash). In the info panel, replace the standalone node-hash line with a Nodes table: our node listed first with its name highlighted, and every node's full public key shown with its path-hash prefix highlighted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
421781f13d
commit
89e1076a06
5 files changed
+227
-47
No files matched your search
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from rich.table import Table
|
||||
|
||||
from ..context import AppContext
|
||||
from .base import Tool, ToolResult, register
|
||||
@@ -32,6 +31,7 @@ class InfoTool(Tool):
|
||||
"""
|
||||
from ..core.device_config import build_snapshot
|
||||
from ..ui.config_editor import render_config
|
||||
from ..ui.widgets import nodes_table
|
||||
|
||||
device = await ctx.device()
|
||||
snapshot = await build_snapshot(device)
|
||||
@@ -41,13 +41,13 @@ class InfoTool(Tool):
|
||||
# Render every current setting with a short explanation of each.
|
||||
render_config(ctx.console, snapshot, custom)
|
||||
|
||||
if contacts:
|
||||
ctable = Table(title=f"Contacts ({len(contacts)})", border_style="muted")
|
||||
ctable.add_column("Name", style="brand")
|
||||
ctable.add_column("Key prefix", style="muted")
|
||||
for c in contacts:
|
||||
ctable.add_row(c.name, c.key_prefix or "[muted]?[/muted]")
|
||||
ctx.console.print(ctable)
|
||||
# List our own node (first, highlighted) and contacts, each with its full key and
|
||||
# the path-hash prefix — the slice a forced path addresses — highlighted.
|
||||
mode = snapshot.get("path_hash_mode")
|
||||
prefix_bytes = (mode + 1) if isinstance(mode, int) and 0 <= mode <= 3 else 0
|
||||
self_name = str(snapshot.get("name") or "this node")
|
||||
self_key = str(snapshot.get("public_key") or "")
|
||||
ctx.console.print(nodes_table(self_name, self_key, contacts, prefix_bytes))
|
||||
|
||||
return ToolResult(
|
||||
summary={"name": snapshot.get("name"), "contacts": len(contacts)},
|
||||
|
||||
@@ -103,6 +103,9 @@ class TraceTool(Tool):
|
||||
# last hop's destination); fall back to a neutral label if it's unavailable.
|
||||
self_info = await device.get_self_info()
|
||||
device_label = str(self_info.get("name") or LOCAL_DEVICE_LABEL)
|
||||
# Our own public key, so the route's endpoints (us) carry a hash like every
|
||||
# other hop, addressed at the same path-hash width.
|
||||
device_hash = str(self_info.get("public_key") or "") or None
|
||||
|
||||
# Resolve repeater hashes in the results to contact names where we know them,
|
||||
# so the tables read as names instead of opaque hex prefixes.
|
||||
@@ -142,8 +145,10 @@ class TraceTool(Tool):
|
||||
# (forced path, or the route the device resolved when auto-routing).
|
||||
current = next((t for t in traces if t.success), None)
|
||||
if traces:
|
||||
ctx.console.print(traces_table(traces, device_label, resolve))
|
||||
ctx.console.print(stats_panel(stats, device_label, resolve, route=current))
|
||||
ctx.console.print(traces_table(traces, device_label, resolve, device_hash))
|
||||
ctx.console.print(
|
||||
stats_panel(stats, device_label, resolve, route=current, device_hash=device_hash)
|
||||
)
|
||||
|
||||
summary: dict[str, Any] = {
|
||||
"target": stats.target,
|
||||
|
||||
+142
-37
@@ -20,6 +20,7 @@ from rich.text import Text
|
||||
from .. import __version__
|
||||
from ..core.models import (
|
||||
LOCAL_DEVICE_LABEL,
|
||||
Contact,
|
||||
HopAggregate,
|
||||
HopEdge,
|
||||
TraceResult,
|
||||
@@ -93,45 +94,37 @@ def make_progress(console: Console) -> Progress:
|
||||
)
|
||||
|
||||
|
||||
def _node_text(
|
||||
label: Optional[str], device_label: str, resolve: NodeResolver = _identity
|
||||
) -> Text:
|
||||
"""Style a node label, making our own device stand out from repeaters.
|
||||
|
||||
Args:
|
||||
label: The node identifier to render; ``None`` means our own device.
|
||||
device_label: The label used for our own device.
|
||||
resolve: Maps a raw hop hash to a friendly name when the node is known.
|
||||
|
||||
Returns:
|
||||
A styled :class:`Text` for the node.
|
||||
"""
|
||||
named = resolve(label) if label else label
|
||||
resolved = named if named else device_label
|
||||
style = "accent" if resolved == device_label else "brand"
|
||||
return Text(resolved, style=style)
|
||||
|
||||
|
||||
def _link_text(
|
||||
origin: Optional[str],
|
||||
destination: Optional[str],
|
||||
device_label: str,
|
||||
resolve: NodeResolver = _identity,
|
||||
hash_bytes: Optional[int] = None,
|
||||
device_hash: Optional[str] = None,
|
||||
) -> Text:
|
||||
"""Render an ``origin -> destination`` link with a styled arrow.
|
||||
"""Render an ``origin -> destination`` link as ``name (hash)`` nodes, arrow styled.
|
||||
|
||||
Each end is rendered like the route line: a known node as ``name (hash)``, an
|
||||
unknown one as its bare hash, and our own device as its label plus ``device_hash``
|
||||
when supplied. Hashes are truncated to ``hash_bytes`` so they match how the trace
|
||||
command addressed each node.
|
||||
|
||||
Args:
|
||||
origin: The transmitting node (``None`` = our device).
|
||||
destination: The receiving node (``None`` = our device).
|
||||
device_label: The label used for our own device.
|
||||
resolve: Maps a raw hop hash to a friendly name when the node is known.
|
||||
hash_bytes: Path-hash width (bytes) to truncate shown hashes to.
|
||||
device_hash: Our own device's key/hash, shown alongside its label when known.
|
||||
|
||||
Returns:
|
||||
A :class:`Text` like ``us → Alice`` with the arrow muted.
|
||||
A :class:`Text` like ``us (a1) → Alice (3d)`` with the arrow muted.
|
||||
"""
|
||||
text = _node_text(origin, device_label, resolve)
|
||||
text = _route_node_text(origin, device_label, resolve, hash_bytes, device_hash)
|
||||
text.append(" → ", style="muted")
|
||||
text.append_text(_node_text(destination, device_label, resolve))
|
||||
text.append_text(
|
||||
_route_node_text(destination, device_label, resolve, hash_bytes, device_hash)
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
@@ -139,24 +132,30 @@ def traces_table(
|
||||
traces: list[TraceResult],
|
||||
device_label: str = LOCAL_DEVICE_LABEL,
|
||||
resolve: NodeResolver = _identity,
|
||||
device_hash: Optional[str] = None,
|
||||
) -> Table:
|
||||
"""Render every trace's per-hop SNR side by side, one column per trace.
|
||||
|
||||
Hops are framed as ``origin -> destination`` links (the first originates at our
|
||||
device, the last returns to it) and aligned by position across traces, so each
|
||||
column is one trace's SNR readings down the shared path. A trailing ``min`` row
|
||||
shows each trace's bottleneck — the per-trace values the run's *median min SNR*
|
||||
summarizes. Traces that never replied appear as a ``✗`` column.
|
||||
column is one trace's SNR readings down the shared path. Each node is annotated with
|
||||
its hash at the command's path-hash width. A trailing ``min`` row shows each trace's
|
||||
bottleneck — the per-trace values the run's *median min SNR* summarizes. Traces that
|
||||
never replied appear as a ``✗`` column.
|
||||
|
||||
Args:
|
||||
traces: The individual traces to display, in run order.
|
||||
device_label: Name to show for our own device at the path's endpoints.
|
||||
resolve: Maps a raw hop hash to a friendly contact name when known.
|
||||
device_hash: Our own device's key/hash, annotated onto the path's endpoints.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Table` with a column per trace.
|
||||
"""
|
||||
target = traces[0].target if traces else ""
|
||||
# All traces in a run share the command's path-hash width; take it from the first
|
||||
# successful one so node hashes render at the width they were addressed.
|
||||
hash_bytes = next((t.path_hash_bytes for t in traces if t.success), None)
|
||||
table = Table(title=f"Trace → {target}", border_style="muted", expand=False)
|
||||
table.add_column("Hop", justify="right", style="muted")
|
||||
table.add_column("From → To")
|
||||
@@ -172,7 +171,10 @@ def traces_table(
|
||||
for idx in indices:
|
||||
link = next(
|
||||
(
|
||||
_link_text(edges[idx].origin, edges[idx].destination, device_label, resolve)
|
||||
_link_text(
|
||||
edges[idx].origin, edges[idx].destination, device_label, resolve,
|
||||
hash_bytes, device_hash,
|
||||
)
|
||||
for edges in edges_by_trace
|
||||
if idx in edges
|
||||
),
|
||||
@@ -201,11 +203,52 @@ def traces_table(
|
||||
return table
|
||||
|
||||
|
||||
def highlighted_hash(value: str, prefix_bytes: int) -> Text:
|
||||
"""Render a full hex key with its leading path-hash prefix highlighted.
|
||||
|
||||
The first ``prefix_bytes`` bytes are the slice other nodes address in a forced trace
|
||||
path (the path-hash); they are shown in the brand colour and the remainder muted, so
|
||||
the addressable prefix stands out within the otherwise full key.
|
||||
|
||||
Args:
|
||||
value: The full key as hex, optionally ``0x``-prefixed and mixed-case.
|
||||
prefix_bytes: Number of leading bytes the current path-hash mode addresses; ``0``
|
||||
(or negative) leaves the whole key un-highlighted.
|
||||
|
||||
Returns:
|
||||
A styled :class:`Text` of the full key.
|
||||
"""
|
||||
raw = value.lower().removeprefix("0x")
|
||||
split = max(0, prefix_bytes) * 2
|
||||
text = Text()
|
||||
if split:
|
||||
text.append(raw[:split], style="brand")
|
||||
text.append(raw[split:], style="muted")
|
||||
else:
|
||||
text.append(raw, style="muted")
|
||||
return text
|
||||
|
||||
|
||||
def _shorten_hash(value: str, hash_bytes: Optional[int]) -> str:
|
||||
"""Return ``value`` as bare hex truncated to ``hash_bytes`` bytes.
|
||||
|
||||
Args:
|
||||
value: A hex hash, optionally ``0x``-prefixed and mixed-case.
|
||||
hash_bytes: Width in bytes to truncate to; the full value when falsy/unknown.
|
||||
|
||||
Returns:
|
||||
Lowercase hex with no ``0x`` prefix, at most ``hash_bytes`` bytes wide.
|
||||
"""
|
||||
raw = value.lower().removeprefix("0x")
|
||||
return raw[: hash_bytes * 2] if hash_bytes else raw
|
||||
|
||||
|
||||
def _route_node_text(
|
||||
label: Optional[str],
|
||||
device_label: str,
|
||||
resolve: NodeResolver,
|
||||
hash_bytes: Optional[int],
|
||||
device_hash: Optional[str] = None,
|
||||
) -> Text:
|
||||
"""Render one route node as ``name (hash)``, the hash at the command's width.
|
||||
|
||||
@@ -219,15 +262,21 @@ def _route_node_text(
|
||||
device_label: The label used for our own device.
|
||||
resolve: Maps a raw hop hash to a friendly contact name when known.
|
||||
hash_bytes: Path-hash width (bytes) to truncate the shown hash to.
|
||||
device_hash: Our own device's key/hash, shown alongside its label when known.
|
||||
|
||||
Returns:
|
||||
A styled :class:`Text` for the node. A non-breaking space joins name and hash
|
||||
so they never split across a line wrap.
|
||||
"""
|
||||
if not label or label == device_label:
|
||||
return Text(device_label, style="accent")
|
||||
raw = label.lower().removeprefix("0x")
|
||||
shown = raw[: hash_bytes * 2] if hash_bytes else raw
|
||||
text = Text(device_label, style="accent")
|
||||
shown = _shorten_hash(device_hash, hash_bytes) if device_hash else ""
|
||||
if shown:
|
||||
text.append(f"{_NBSP}(", style="muted") # nbsp keeps "name (hash)" together
|
||||
text.append(shown, style="muted")
|
||||
text.append(")", style="muted")
|
||||
return text
|
||||
shown = _shorten_hash(label, hash_bytes)
|
||||
named = resolve(label)
|
||||
if not named or named == label:
|
||||
return Text(shown, style="brand") # unknown node: hash only
|
||||
@@ -242,19 +291,21 @@ def _route_text(
|
||||
result: TraceResult,
|
||||
device_label: str = LOCAL_DEVICE_LABEL,
|
||||
resolve: NodeResolver = _identity,
|
||||
device_hash: Optional[str] = None,
|
||||
) -> Text:
|
||||
"""Render a trace's route as a sequence of ``name (hash)`` nodes.
|
||||
|
||||
Shows the path the trace actually walked — the forced path, or the route the
|
||||
device resolved when auto-routing — with each node annotated by its hash at the
|
||||
command's path-hash width, joined by muted arrows, e.g.
|
||||
``Me → Alice (3d63) → Bob (f2a1) → Me``. The line wraps after an arrow when it is
|
||||
too long for the panel, so each continuation line starts on a node.
|
||||
``Me (a1b2) → Alice (3d63) → Bob (f2a1) → Me (a1b2)``. The line wraps after an arrow
|
||||
when it is too long for the panel, so each continuation line starts on a node.
|
||||
|
||||
Args:
|
||||
result: The trace whose route to display.
|
||||
device_label: Name to show for our own device at the path's endpoints.
|
||||
resolve: Maps a raw hop hash to a friendly contact name when known.
|
||||
device_hash: Our own device's key/hash, annotated onto its endpoints when known.
|
||||
|
||||
Returns:
|
||||
A :class:`Text` with the node sequence, or a muted note when no hops exist.
|
||||
@@ -270,12 +321,18 @@ def _route_text(
|
||||
# A non-breaking space glues the arrow to the preceding node; the trailing
|
||||
# regular space is the only wrap point, so wrapping lands *after* the arrow.
|
||||
text.append(f"{_NBSP}→ ", style="muted")
|
||||
text.append_text(_route_node_text(node, device_label, resolve, hash_bytes))
|
||||
text.append_text(
|
||||
_route_node_text(node, device_label, resolve, hash_bytes, device_hash)
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def _hop_medians_table(
|
||||
hop_snrs: list[HopAggregate], device_label: str, resolve: NodeResolver = _identity
|
||||
hop_snrs: list[HopAggregate],
|
||||
device_label: str,
|
||||
resolve: NodeResolver = _identity,
|
||||
hash_bytes: Optional[int] = None,
|
||||
device_hash: Optional[str] = None,
|
||||
) -> Table:
|
||||
"""Render the per-hop median SNR aggregated across a run's traces.
|
||||
|
||||
@@ -283,6 +340,8 @@ def _hop_medians_table(
|
||||
hop_snrs: The per-hop aggregates to display.
|
||||
device_label: Name to show for our own device at the path's endpoints.
|
||||
resolve: Maps a raw hop hash to a friendly contact name when known.
|
||||
hash_bytes: Path-hash width (bytes) to truncate shown node hashes to.
|
||||
device_hash: Our own device's key/hash, annotated onto the path's endpoints.
|
||||
|
||||
Returns:
|
||||
A compact Rich :class:`Table` of hop, link, and median SNR.
|
||||
@@ -294,7 +353,9 @@ def _hop_medians_table(
|
||||
for agg in hop_snrs:
|
||||
table.add_row(
|
||||
str(agg.index),
|
||||
_link_text(agg.origin, agg.destination, device_label, resolve),
|
||||
_link_text(
|
||||
agg.origin, agg.destination, device_label, resolve, hash_bytes, device_hash
|
||||
),
|
||||
Text(f"{agg.median_snr:+.1f} dB", style=snr_style(agg.median_snr)),
|
||||
)
|
||||
return table
|
||||
@@ -305,6 +366,7 @@ def stats_panel(
|
||||
device_label: str = LOCAL_DEVICE_LABEL,
|
||||
resolve: NodeResolver = _identity,
|
||||
route: Optional[TraceResult] = None,
|
||||
device_hash: Optional[str] = None,
|
||||
) -> Panel:
|
||||
"""Summarize aggregated trace statistics in a panel.
|
||||
|
||||
@@ -318,6 +380,7 @@ def stats_panel(
|
||||
device_label: Name to show for our own device at the path's endpoints.
|
||||
resolve: Maps a raw hop hash to a friendly contact name when known.
|
||||
route: A representative trace whose walked route to display, if any.
|
||||
device_hash: Our own device's key/hash, annotated onto the route endpoints.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Panel` with the route, success rate, robust SNR/RTT, and
|
||||
@@ -336,16 +399,58 @@ def stats_panel(
|
||||
sections: list[Text | Table] = []
|
||||
if route is not None:
|
||||
sections.append(Text("route", style="muted"))
|
||||
sections.append(_route_text(route, device_label, resolve))
|
||||
sections.append(_route_text(route, device_label, resolve, device_hash))
|
||||
sections.append(Text()) # blank line before the stats block
|
||||
sections.append(summary)
|
||||
if stats.hop_snrs:
|
||||
sections.append(Text("\nper-hop medians", style="muted"))
|
||||
sections.append(_hop_medians_table(stats.hop_snrs, device_label, resolve))
|
||||
hash_bytes = route.path_hash_bytes if route is not None else None
|
||||
sections.append(
|
||||
_hop_medians_table(stats.hop_snrs, device_label, resolve, hash_bytes, device_hash)
|
||||
)
|
||||
body: Text | Group = sections[0] if len(sections) == 1 else Group(*sections)
|
||||
return Panel(body, title="[accent]trace summary[/accent]", border_style="accent", expand=False)
|
||||
|
||||
|
||||
def nodes_table(
|
||||
self_name: str,
|
||||
self_key: str,
|
||||
contacts: list[Contact],
|
||||
prefix_bytes: int,
|
||||
) -> Table:
|
||||
"""List this node and its known contacts, full keys with the path-hash prefix lit.
|
||||
|
||||
Our own node is the first row, its name highlighted to set it apart. Every node's key
|
||||
is shown in full with the leading ``prefix_bytes`` bytes — the slice a forced trace
|
||||
path addresses at the current path-hash mode — highlighted.
|
||||
|
||||
Args:
|
||||
self_name: This node's advertised name.
|
||||
self_key: This node's full public key (hex); blank renders as ``?``.
|
||||
contacts: Known contacts, listed after our own node.
|
||||
prefix_bytes: Path-hash width in bytes to highlight in every key.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Table` of name and full public key.
|
||||
"""
|
||||
table = Table(title=f"Nodes ({len(contacts)} contacts)", border_style="muted")
|
||||
table.add_column("Name")
|
||||
table.add_column("Public key [muted](path-hash prefix highlighted)[/muted]")
|
||||
|
||||
unknown = Text("?", style="muted")
|
||||
table.add_row(
|
||||
Text(f"{self_name} (this node)", style="accent"), # our node, name highlighted
|
||||
highlighted_hash(self_key, prefix_bytes) if self_key else unknown,
|
||||
)
|
||||
table.add_section()
|
||||
for c in contacts:
|
||||
table.add_row(
|
||||
Text(c.name, style="brand"),
|
||||
highlighted_hash(c.public_key, prefix_bytes) if c.public_key else unknown,
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def tx_opt_table(result: TxOptResult) -> Table:
|
||||
"""Render every measured TX level of an optimization sweep, best row highlighted.
|
||||
|
||||
|
||||
@@ -69,6 +69,35 @@ def test_path_hash_mode_is_strict_enum() -> None:
|
||||
parse_value(get_spec("path_hash_mode"), "4")
|
||||
|
||||
|
||||
def test_highlighted_hash_highlights_path_hash_prefix() -> None:
|
||||
"""The full key is shown with only its path-hash prefix bytes highlighted."""
|
||||
from meshtools.ui.widgets import highlighted_hash
|
||||
|
||||
pub = "aabbccddee" + "00" * 27
|
||||
# mode 2 => 3-byte hashes => first 6 hex chars are the addressable prefix.
|
||||
text = highlighted_hash(pub, prefix_bytes=3)
|
||||
assert text.plain == pub # the full key is shown
|
||||
highlighted = [s for s in text.spans if s.style == "brand"]
|
||||
assert len(highlighted) == 1
|
||||
assert text.plain[highlighted[0].start : highlighted[0].end] == "aabbcc"
|
||||
|
||||
|
||||
def test_nodes_table_lists_us_first_with_full_keys() -> None:
|
||||
"""The nodes table puts our node first (name highlighted) with full keys."""
|
||||
from meshtools.core.models import Contact
|
||||
from meshtools.ui.widgets import nodes_table
|
||||
|
||||
contacts = [Contact(name="Alice", public_key="3d63c6" + "00" * 29, key_prefix="3d63c6")]
|
||||
table = nodes_table("Homestead", "aabbcc" + "00" * 29, contacts, prefix_bytes=3)
|
||||
# Two body rows: our node, then Alice.
|
||||
names = [cell.plain if hasattr(cell, "plain") else str(cell) for cell in table.columns[0].cells]
|
||||
assert names[0].startswith("Homestead")
|
||||
assert "Alice" in names[1]
|
||||
keys = list(table.columns[1].cells)
|
||||
assert keys[0].plain == "aabbcc" + "00" * 29 # full key, not a short prefix
|
||||
assert any(s.style == "brand" for s in keys[0].spans) # prefix highlighted
|
||||
|
||||
|
||||
def test_non_strict_enum_accepts_unlisted_value() -> None:
|
||||
"""multi_acks/adv_loc_policy list common values but still accept other ints."""
|
||||
spec = get_spec("multi_acks")
|
||||
|
||||
@@ -118,6 +118,47 @@ def test_route_text_annotates_nodes_with_command_width_hash() -> None:
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user