Pressing it in the packet viewer closed the viewer and stopped there, then fired at whatever the reader pressed next. A screen opened from a key handler cannot be awaited by its opener — a handler is sync, so the live feed floats the viewer with a detached task — and the sentinel went only to the top screen, so the unwind it raised had nowhere to propagate and died in that task. The live feed below was meanwhile parked on a visit.result() that no press would resolve, and the flag stayed armed to unwind at the next navigation boundary the reader happened to cross. The chat's delivery paths and the trace screen's path flows open the same way and had the same fault. The unwind now arms every frame on the stack, top first, down to but never including the root, stopping at anything modal for the reason the top-of-stack refusal already gives. Every frame is on the stack whether or not something awaits it, so the stack is the reliable path down; resolving a frame nothing is waiting on is inert. The detached launches go through session.run_detached, which absorbs the PopToMenu its task cannot carry — a duplicate of an unwind already travelling out through the screen that opened it — so a press no longer leaves an unretrieved task error behind. Flows keep their own finally blocks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1278 lines
50 KiB
Python
1278 lines
50 KiB
Python
"""Unit tests for the live Trace and TX-optimize screens.
|
||
|
||
These drive the two full-screen tools' pure logic — trace/sweep state machines, key
|
||
handling, and rendering — against fake sessions and injected runners, so they run fast
|
||
and headless (the same approach as ``test_tui``).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from datetime import timedelta
|
||
|
||
import pytest
|
||
from rich.cells import cell_len
|
||
|
||
from meshterm.core.models import Contact, Hop, TraceResult, TraceStats, TxLevelResult, \
|
||
TxOptResult, utcnow
|
||
from meshterm.services.topology import PathScenario, build_topology
|
||
from meshterm.ui.braillechart import _METER_SLIM
|
||
from meshterm.ui.theme import snr_style
|
||
from meshterm.ui.trace_screen import (
|
||
_BAR_WIDTH, OPEN_TROPHY_CASE, TraceScreen, _scenario_detail, _scenario_path, snr_bar,
|
||
)
|
||
from meshterm.ui.tx_screen import TxSweepScreen
|
||
|
||
# The slim meter's fill glyphs (the SNR bar draws through the shared braille meter).
|
||
_BAR_FULL, _BAR_HALF = _METER_SLIM
|
||
|
||
|
||
class _FakeSession:
|
||
"""The session capabilities the screens use directly: repaints and the dialog stack."""
|
||
|
||
def __init__(self) -> None:
|
||
self.repaints = 0
|
||
self.stack: list = []
|
||
#: Every button_dialog floated, as ``(prompt, buttons, kwargs)``; the next
|
||
#: one resolves with ``dialog_answer`` (``None`` = the Esc/idle-cancel path).
|
||
self.dialogs: list = []
|
||
self.dialog_answer = None
|
||
|
||
def invalidate(self) -> None:
|
||
self.repaints += 1
|
||
|
||
def push(self, screen) -> None: # noqa: ANN001
|
||
self.stack.append(screen)
|
||
|
||
def pop(self, screen=None) -> None: # noqa: ANN001
|
||
if screen is None:
|
||
self.stack.pop()
|
||
elif screen in self.stack:
|
||
self.stack.remove(screen)
|
||
|
||
async def button_dialog(self, prompt, buttons, **kwargs): # noqa: ANN001
|
||
self.dialogs.append((prompt, buttons, kwargs))
|
||
return self.dialog_answer
|
||
|
||
def run_detached(self, work): # noqa: ANN001, ANN201
|
||
"""Start a key handler's flow as a task, as ``TuiSession.run_detached`` does."""
|
||
return asyncio.ensure_future(work)
|
||
|
||
|
||
def _trace(*snrs: float, success: bool = True, target: str = "Alice") -> TraceResult:
|
||
"""Build a trace with one hop per SNR reading."""
|
||
hops = [Hop(index=i, node=f"{i:02x}{i:02x}", snr=snr) for i, snr in enumerate(snrs)]
|
||
return TraceResult(
|
||
target=target, success=success, hops=hops, round_trip_ms=200.0, path_hash_bytes=2
|
||
)
|
||
|
||
|
||
from tests.conftest import plain as _plain # THE strip-and-join screen reader
|
||
|
||
|
||
# --- snr_bar -------------------------------------------------------------------
|
||
|
||
|
||
def _lit_plain(bar) -> str: # noqa: ANN001
|
||
"""The reading's own coloured prefix, stripped of the dimmed unlit track.
|
||
|
||
The unlit remainder reuses :data:`_BAR_FULL` too (dimmed to the ``track`` style
|
||
instead of a distinct glyph), so telling lit from unlit means reading which
|
||
style each character actually landed in, not just which glyph it is.
|
||
"""
|
||
if bar.style == "track":
|
||
return ""
|
||
track_spans = [s for s in bar.spans if s.style == "track"]
|
||
end = min((s.start for s in track_spans), default=len(bar.plain))
|
||
return bar.plain[:end]
|
||
|
||
|
||
def _filled_steps(bar) -> int: # noqa: ANN001
|
||
"""Count a rendered bar's fill steps — two per full braille cell, one per half."""
|
||
plain = _lit_plain(bar)
|
||
return plain.count(_BAR_FULL) * 2 + plain.count(_BAR_HALF)
|
||
|
||
|
||
def test_snr_bar_scales_with_signal_quality() -> None:
|
||
"""A stronger signal fills more of the track; None renders an entirely unlit one."""
|
||
weak = snr_bar(-12.0)
|
||
strong = snr_bar(8.0)
|
||
assert _filled_steps(weak) < _filled_steps(strong)
|
||
assert len(weak.plain) == len(strong.plain) == _BAR_WIDTH # track width is constant
|
||
none_bar = snr_bar(None)
|
||
assert _filled_steps(none_bar) == 0
|
||
assert none_bar.style == "track" # the whole track dims, not a separate faint dot run
|
||
|
||
|
||
def test_snr_bar_clamps_out_of_range_readings() -> None:
|
||
"""Readings beyond the display range clamp to the ends instead of over/underflowing."""
|
||
assert _filled_steps(snr_bar(99.0)) == _filled_steps(snr_bar(10.0))
|
||
assert _filled_steps(snr_bar(-99.0)) == 1 # a heard hop always shows something
|
||
|
||
|
||
def test_snr_bar_packs_two_steps_per_character() -> None:
|
||
"""16 steps of resolution pack into 8 characters: full cells, then one trailing half."""
|
||
one_step = snr_bar(-13.4375) # frac = 1/16 of the -15..+10 span
|
||
assert one_step.plain[0] == _BAR_HALF
|
||
assert one_step.plain[1:] == _BAR_FULL * (_BAR_WIDTH - 1) # unlit track, same glyph
|
||
track_spans = [(s.start, s.end) for s in one_step.spans if s.style == "track"]
|
||
assert track_spans == [(1, _BAR_WIDTH)]
|
||
|
||
three_steps = snr_bar(-10.3125) # frac = 3/16 → one full cell, one half
|
||
assert three_steps.plain[:2] == _BAR_FULL + _BAR_HALF
|
||
assert three_steps.plain[2:] == _BAR_FULL * (_BAR_WIDTH - 2)
|
||
|
||
|
||
def test_snr_bar_unlit_track_dims_to_a_distinct_style() -> None:
|
||
"""The unlit track renders in ``track``, not the reading's colour or old ``faint`` dots."""
|
||
bar = snr_bar(-10.3125)
|
||
assert "·" not in bar.plain # no more plain-dot placeholder
|
||
track_span = next(s for s in bar.spans if s.style == "track")
|
||
assert bar.plain[track_span.start : track_span.end] == _BAR_FULL * (_BAR_WIDTH - 2)
|
||
assert bar.style == snr_style(-10.3125) # the lit prefix still carries the reading's colour
|
||
|
||
assert snr_bar(10.0).plain == _BAR_FULL * _BAR_WIDTH # top of range: every cell full
|
||
|
||
|
||
# --- _previous_outbound -----------------------------------------------------------
|
||
|
||
|
||
def _walk(*nodes: str, success: bool = True) -> TraceResult:
|
||
"""A stored walk whose hop hashes are ``nodes`` (plus the final hash-less us)."""
|
||
hops = [Hop(index=i, node=n, snr=1.0) for i, n in enumerate(nodes)]
|
||
hops.append(Hop(index=len(nodes), node=None, snr=1.0))
|
||
return TraceResult(
|
||
target="Alice", success=success, hops=hops, round_trip_ms=200.0, path_hash_bytes=2
|
||
)
|
||
|
||
|
||
def test_previous_outbound_extracts_the_proven_route() -> None:
|
||
"""The last successful boomerang's first half is the reusable outbound leg.
|
||
|
||
Verified on hardware that the device itself almost never has a learned route
|
||
(contacts report flood), so this stored evidence is what auto mode actually
|
||
walks for a multi-hop target.
|
||
"""
|
||
from meshterm.ui.trace_screen import _previous_outbound
|
||
|
||
walk = _walk("3d63", "f2c2", "aabb", "f2c2", "3d63")
|
||
assert _previous_outbound(walk, "aabb" + "00" * 30) == ("3d63", "f2c2")
|
||
|
||
|
||
def test_previous_outbound_direct_walk_yields_no_repeaters() -> None:
|
||
"""A direct answer (target only) extracts an empty outbound leg — dest-only again."""
|
||
from meshterm.ui.trace_screen import _previous_outbound
|
||
|
||
assert _previous_outbound(_walk("aabb"), "aabb" + "00" * 30) == ()
|
||
|
||
|
||
def test_previous_outbound_rejects_unusable_history() -> None:
|
||
"""Failures, asymmetric walks, and walks that turned elsewhere are never reused."""
|
||
from meshterm.ui.trace_screen import _previous_outbound
|
||
|
||
target = "aabb" + "00" * 30
|
||
assert _previous_outbound(None, target) is None
|
||
assert _previous_outbound(_walk("3d63", "aabb", "3d63", success=False), target) is None
|
||
# Asymmetric: came home a different way — not a boomerang to this target.
|
||
assert _previous_outbound(_walk("3d63", "aabb", "f2c2"), target) is None
|
||
# Palindromic, but it turned at some other node, not our target.
|
||
assert _previous_outbound(_walk("3d63", "9999", "3d63"), target) is None
|
||
|
||
|
||
# --- _previous_walk ---------------------------------------------------------------
|
||
|
||
|
||
def test_previous_walk_returns_the_whole_proven_route() -> None:
|
||
"""The last successful path walk comes back verbatim — it's a whole spec already.
|
||
|
||
Unlike a target-mode boomerang there is no shape to recognise: whatever route
|
||
the mesh carried end to end is the route to offer again, hop hashes at the
|
||
width they were transmitted.
|
||
"""
|
||
from meshterm.ui.trace_screen import _previous_walk
|
||
|
||
walk = _walk("3d63", "f2c2", "27ab")
|
||
assert _previous_walk(walk) == ("3d63", "f2c2", "27ab")
|
||
|
||
|
||
def test_previous_walk_rejects_unusable_history() -> None:
|
||
"""No history, a failed walk, or one with no addressable hops is never reused."""
|
||
from meshterm.ui.trace_screen import _previous_walk
|
||
|
||
assert _previous_walk(None) is None
|
||
assert _previous_walk(_walk("3d63", success=False)) is None
|
||
assert _previous_walk(_walk()) is None # only the hash-less final hop (us)
|
||
|
||
|
||
# --- TraceScreen ----------------------------------------------------------------
|
||
|
||
|
||
def _trace_screen(
|
||
trace=None, compose_path=None, explore=None, pick_width=None, pick_samples=None,
|
||
previous=None, mode="target", samples=1, auto_spec=None, auto_source="",
|
||
open_trophy_case=None,
|
||
) -> tuple[TraceScreen, _FakeSession]:
|
||
session = _FakeSession()
|
||
|
||
async def default_trace(path_spec, on_trace): # noqa: ANN001
|
||
on_trace(_trace(5.0, 2.0))
|
||
|
||
async def default_flow(current): # noqa: ANN001
|
||
return current
|
||
|
||
screen = TraceScreen(
|
||
"Alice" if mode == "target" else "(path)",
|
||
mode=mode,
|
||
device_label="Us",
|
||
device_hash="aabb" + "00" * 30,
|
||
resolve=lambda label: label,
|
||
session=session,
|
||
trace=trace or default_trace,
|
||
compose_path=compose_path or default_flow,
|
||
explore=(explore or default_flow) if mode == "target" else None,
|
||
pick_width=pick_width or default_flow,
|
||
pick_samples=pick_samples or default_flow,
|
||
width_bytes=lambda: 2,
|
||
sample_count=lambda: samples,
|
||
pace_s=0.0, # tests never sleep; pacing is asserted through the statuses
|
||
previous=previous,
|
||
auto_spec=auto_spec or (lambda: ""),
|
||
auto_source=auto_source,
|
||
open_trophy_case=open_trophy_case,
|
||
)
|
||
screen.note_viewport(40) # the frame records this before every real paint
|
||
return screen, session
|
||
|
||
|
||
async def test_trace_screen_one_trace_per_enter_accumulates() -> None:
|
||
"""At the default sample count, each Enter transmits exactly one trace.
|
||
|
||
Repeat sampling stays a human decision unless a bigger sample count is chosen
|
||
explicitly, so three keypresses mean three traces and a three-sample median.
|
||
"""
|
||
screen, _ = _trace_screen()
|
||
for _ in range(3):
|
||
screen.start_trace()
|
||
await screen._worker
|
||
body = _plain(screen.render_body(100))
|
||
assert "success rate" in body and "3/3" in body
|
||
assert "#3" in body # newest-first numbering
|
||
assert "Per-hop medians" in body
|
||
assert "burst" not in body.lower() # no burst configuration is offered anywhere
|
||
|
||
|
||
async def test_trace_screen_runs_the_chosen_sample_count() -> None:
|
||
"""One Enter runs the whole chosen sample count, every trace recorded."""
|
||
ran: list[str] = []
|
||
|
||
async def trace(path_spec, on_trace): # noqa: ANN001
|
||
ran.append(path_spec)
|
||
on_trace(_trace(5.0))
|
||
|
||
screen, session = _trace_screen(trace=trace, samples=3)
|
||
screen.start_trace()
|
||
await screen._worker
|
||
assert len(ran) == 3
|
||
assert len(screen._traces) == 3
|
||
assert session.stack == [] # the dialog was popped with the run
|
||
|
||
|
||
async def test_trace_screen_multi_trace_reports_progress_and_abort_keeps_landed() -> None:
|
||
"""A multi-trace run counts itself off; aborting keeps what already landed."""
|
||
release = asyncio.Event()
|
||
ran = 0
|
||
|
||
async def trace(path_spec, on_trace): # noqa: ANN001
|
||
nonlocal ran
|
||
ran += 1
|
||
on_trace(_trace(5.0))
|
||
if ran == 2:
|
||
await release.wait() # hold the run mid-flight on the second trace
|
||
|
||
screen, session = _trace_screen(trace=trace, samples=5)
|
||
screen.start_trace()
|
||
await asyncio.sleep(0)
|
||
dialog = session.stack[0]
|
||
assert "2/5" in dialog.status # the dialog counts the run off
|
||
assert "2/5" in screen.footer_hint
|
||
assert "2/5" in _plain(screen.render_body(100)) # the log spinner row too
|
||
screen.cancel()
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await screen._worker
|
||
assert len(screen._traces) == 2 # already-recorded traces are kept
|
||
assert session.stack == []
|
||
|
||
|
||
async def test_trace_screen_seeds_route_from_previous_trace() -> None:
|
||
"""Before any fresh reply, the stored route shows, marked as previous."""
|
||
old = _trace(4.0)
|
||
old.timestamp = utcnow() - timedelta(hours=3)
|
||
screen, _ = _trace_screen(previous=old)
|
||
body = _plain(screen.render_body(100))
|
||
assert "(previous" in body
|
||
# A fresh success replaces the seeded route and drops the marker.
|
||
screen._on_trace(_trace(6.0))
|
||
body = _plain(screen.render_body(100))
|
||
assert "(previous" not in body
|
||
|
||
|
||
async def test_previous_stamp_sits_on_its_own_line() -> None:
|
||
"""The (previous · …) marker renders under the route, never squeezed beside it."""
|
||
old = _trace(4.0)
|
||
old.timestamp = utcnow() - timedelta(hours=3)
|
||
screen, _ = _trace_screen(previous=old)
|
||
stamp_line = next(l for l in screen.render_body(100) if "(previous" in l)
|
||
assert "→" not in stamp_line # the route stays on the line above
|
||
|
||
|
||
async def test_trace_screen_only_one_trace_at_a_time() -> None:
|
||
"""Enter during an in-flight trace is a no-op; the running flag gates re-entry."""
|
||
started = 0
|
||
release = asyncio.Event()
|
||
|
||
async def trace(path_spec, on_trace): # noqa: ANN001
|
||
nonlocal started
|
||
started += 1
|
||
await release.wait()
|
||
|
||
screen, _ = _trace_screen(trace=trace)
|
||
screen.start_trace()
|
||
assert screen._running
|
||
screen.handle("enter") # ignored while running
|
||
release.set()
|
||
await screen._worker
|
||
assert started == 1
|
||
assert not screen._running
|
||
|
||
|
||
async def test_trace_screen_failure_reads_inline() -> None:
|
||
"""A failed trace reports its error in the log area instead of crashing the screen."""
|
||
|
||
async def trace(path_spec, on_trace): # noqa: ANN001
|
||
raise RuntimeError("no route")
|
||
|
||
screen, _ = _trace_screen(trace=trace)
|
||
screen.start_trace()
|
||
await screen._worker
|
||
assert "trace failed: no route" in _plain(screen.render_body(100))
|
||
|
||
|
||
async def test_trace_screen_composer_updates_the_spec() -> None:
|
||
"""Committing Compose path runs the flow; its result becomes the next trace's path."""
|
||
asked: list[str] = []
|
||
|
||
async def compose(current: str): # noqa: ANN001
|
||
asked.append(current)
|
||
return "3d,f2,3d" # one forced hop: outbound, target, then the mirrored return
|
||
|
||
screen, _ = _trace_screen(compose_path=compose)
|
||
screen.handle("down") # Trace is the last row, so ↓ wraps onto Compose path
|
||
screen.handle("enter")
|
||
await asyncio.sleep(0)
|
||
assert asked == [""]
|
||
assert screen._path_spec == "3d,f2,3d"
|
||
body = _plain(screen.render_body(100))
|
||
assert "3d,f2,3d" in body
|
||
# the planned route previews outbound *and* the resolved, dimmed return leg,
|
||
# our own ends spending no words — just the star that stands for us
|
||
assert "route ★ → 3d → f2 → 3d → ★" in body
|
||
assert body.count("3d") >= 2
|
||
|
||
|
||
async def test_compose_seeds_from_the_visible_route_not_the_empty_spec() -> None:
|
||
"""Opening Compose resumes from the route on screen, not the stored spec.
|
||
|
||
On first open nothing has been composed, but the screen already shows the
|
||
auto-resolved plan; the composer must open seeded with that plan's hops so the
|
||
user edits the route they see rather than starting from a blank path.
|
||
"""
|
||
asked: list[str] = []
|
||
|
||
async def compose(current: str): # noqa: ANN001
|
||
asked.append(current)
|
||
return None # observe the seed only; leave the (auto) plan untouched
|
||
|
||
screen, _ = _trace_screen(
|
||
mode="path", compose_path=compose,
|
||
auto_spec=lambda: "3d,f2", auto_source="last walk",
|
||
)
|
||
screen._index = screen._actions.index("compose")
|
||
screen.handle("enter")
|
||
await asyncio.sleep(0)
|
||
assert asked == ["3d,f2"] # seeded from the visible auto plan, not ""
|
||
assert screen._path_spec == "" # None keeps the plan auto (nothing pinned)
|
||
|
||
|
||
async def test_trace_screen_explore_adopts_a_scenario_path() -> None:
|
||
"""Committing Explore paths runs the flow; adopting sets the spec, None keeps it."""
|
||
|
||
async def adopt(current: str): # noqa: ANN001
|
||
return "3d63,f2c2"
|
||
|
||
screen, _ = _trace_screen(explore=adopt)
|
||
for _ in range(3):
|
||
screen.handle("up") # Trace → Sample count → Path width → Explore paths
|
||
screen.handle("enter")
|
||
await asyncio.sleep(0)
|
||
assert screen._path_spec == "3d63,f2c2"
|
||
|
||
async def keep(current: str): # noqa: ANN001
|
||
return None
|
||
|
||
screen._explore = keep
|
||
screen.handle("enter") # the cursor is still on Explore paths
|
||
await asyncio.sleep(0)
|
||
assert screen._path_spec == "3d63,f2c2" # None leaves the spec untouched
|
||
|
||
|
||
async def test_trace_screen_action_labels_share_one_column() -> None:
|
||
"""Every action's label starts in the same column, wide mark or narrow.
|
||
|
||
``⚡`` is an emoji — two cells where ``✎``/``⚙``/``#``/``▶`` are one — so a fixed
|
||
``"icon "`` prefix would start Explore's label a column right of the rest.
|
||
"""
|
||
screen, _ = _trace_screen()
|
||
labels = ["Compose path", "Explore paths", "Path width", "Sample count", "Trace —"]
|
||
columns = set()
|
||
for row in _plain(screen.render_body(100)).splitlines():
|
||
for label in labels:
|
||
if label in row:
|
||
columns.add(cell_len(row[: row.index(label)]))
|
||
assert len(columns) == 1, columns
|
||
|
||
|
||
async def test_trace_screen_action_cursor_commits_the_selected_row() -> None:
|
||
"""↑↓ move over the action rows; Enter commits the one under the cursor.
|
||
|
||
The rows are the screen's own verbs and nothing else — no exit row closes them, so
|
||
Trace is the last stop and ↓ from it wraps straight back to Compose path.
|
||
"""
|
||
opened: list[str] = []
|
||
|
||
async def width_flow(current): # noqa: ANN001
|
||
opened.append(f"width:{current}")
|
||
return "3d63,f2c2,3d63"
|
||
|
||
screen, _ = _trace_screen(pick_width=width_flow)
|
||
body = _plain(screen.render_body(100))
|
||
# The menu order the actions read in: build first, tune, then transmit.
|
||
labels = ["Compose path", "Explore paths", "Path width — 2 bytes per hop",
|
||
"Sample count — 1 trace", "Trace — one transmission"]
|
||
positions = [body.index(label) for label in labels]
|
||
assert positions == sorted(positions)
|
||
screen.handle("up") # Trace → Sample count
|
||
screen.handle("up") # → Path width
|
||
screen.handle("enter")
|
||
await asyncio.sleep(0)
|
||
assert opened == ["width:"]
|
||
assert screen._path_spec == "3d63,f2c2,3d63"
|
||
|
||
|
||
async def test_trace_screen_sample_count_row_opens_its_dialog() -> None:
|
||
"""Committing Sample count floats the flow; its None resolution keeps the spec."""
|
||
opened: list[str] = []
|
||
|
||
async def samples_flow(current): # noqa: ANN001
|
||
opened.append(current)
|
||
return None
|
||
|
||
screen, _ = _trace_screen(pick_samples=samples_flow)
|
||
screen._path_spec = "3d,f2,3d"
|
||
screen.handle("up") # Trace → Sample count
|
||
screen.handle("enter")
|
||
await asyncio.sleep(0)
|
||
assert opened == ["3d,f2,3d"]
|
||
assert screen._path_spec == "3d,f2,3d" # the count is not a spec: nothing changes
|
||
|
||
|
||
async def test_trace_screen_hotkeys_are_retired() -> None:
|
||
"""The old w/p/x shortcuts are gone: typing must not float any flow."""
|
||
opened: list[str] = []
|
||
|
||
async def flow(current): # noqa: ANN001
|
||
opened.append(current)
|
||
return None
|
||
|
||
screen, _ = _trace_screen(compose_path=flow, explore=flow, pick_width=flow,
|
||
pick_samples=flow)
|
||
for key in ("p", "x", "w", "s"):
|
||
screen.handle("text", key)
|
||
await asyncio.sleep(0)
|
||
assert opened == []
|
||
assert not screen._running
|
||
|
||
|
||
async def test_trace_screen_carries_no_exit_row() -> None:
|
||
"""The screen offers only its own verbs; Esc is the way out, and Enter never is."""
|
||
screen, _ = _trace_screen()
|
||
screen.future = asyncio.get_running_loop().create_future()
|
||
assert "Back" not in _plain(screen.render_body(100))
|
||
screen.handle("down") # Trace wraps to Compose path, not onto an exit row
|
||
assert not screen.future.done()
|
||
screen.handle("escape")
|
||
assert screen.future.result() is None
|
||
|
||
|
||
async def test_composer_commit_parks_the_cursor_on_trace() -> None:
|
||
"""Committing the composer hands the cursor to Trace, so plain Enter walks it."""
|
||
|
||
async def compose(current): # noqa: ANN001
|
||
return "3d,f2,3d"
|
||
|
||
screen, _ = _trace_screen(compose_path=compose)
|
||
screen._index = screen._actions.index("compose")
|
||
screen.handle("enter")
|
||
await asyncio.sleep(0)
|
||
assert screen._actions[screen._index] == "trace"
|
||
|
||
# The hand-back is the composer's alone: the width flow keeps the cursor put.
|
||
async def width(current): # noqa: ANN001
|
||
return "3d63,f2c2,3d63"
|
||
|
||
other, _ = _trace_screen(pick_width=width)
|
||
other._index = other._actions.index("width")
|
||
other.handle("enter")
|
||
await asyncio.sleep(0)
|
||
assert other._actions[other._index] == "width"
|
||
|
||
|
||
async def test_composer_escape_leaves_the_cursor_where_it_was() -> None:
|
||
"""Backing out of the composer with Esc moves nothing."""
|
||
|
||
async def compose(current): # noqa: ANN001
|
||
return None # the composer's Esc resolution
|
||
|
||
screen, _ = _trace_screen(compose_path=compose)
|
||
screen._index = screen._actions.index("compose")
|
||
screen.handle("enter")
|
||
await asyncio.sleep(0)
|
||
assert screen._actions[screen._index] == "compose"
|
||
|
||
|
||
# --- the no-cheat rule on Trace ------------------------------------------------------
|
||
|
||
|
||
async def _settle(screen) -> None: # noqa: ANN001
|
||
"""Let a just-committed dialog task run, then any trace worker it started."""
|
||
for _ in range(5):
|
||
await asyncio.sleep(0)
|
||
if screen._worker is not None:
|
||
await screen._worker
|
||
|
||
|
||
async def test_ineligible_walk_gates_trace_behind_an_amber_confirm() -> None:
|
||
"""A same-direction link recross floats Cancel/Trace and cancels cleanly."""
|
||
ran: list[str] = []
|
||
|
||
async def trace(path_spec, on_trace): # noqa: ANN001
|
||
ran.append(path_spec)
|
||
on_trace(_trace(5.0))
|
||
|
||
screen, session = _trace_screen(trace=trace, mode="path")
|
||
screen._path_spec = "3d,f2,3d,f2" # rides 3d → f2 twice the same way
|
||
screen._index = screen._actions.index("trace")
|
||
screen.handle("enter") # dialog_answer None = Cancel/Esc
|
||
await _settle(screen)
|
||
prompt, buttons, kwargs = session.dialogs[0]
|
||
assert [label for label, _ in buttons] == ["Cancel", "Trace"]
|
||
assert kwargs["default"] == 1 # Trace on the right and default: Enter commits it
|
||
assert kwargs["border_style"] == "warn" # the amber caution tier
|
||
assert "trail" in prompt.plain # the graph-theory name reaches the user
|
||
assert ran == [] # cancelled: nothing transmitted
|
||
|
||
session.dialog_answer = "trace"
|
||
screen.handle("enter")
|
||
await _settle(screen)
|
||
assert ran == ["3d,f2,3d,f2"] # confirmed: the walk still flies
|
||
|
||
|
||
async def test_eligible_walk_traces_without_a_confirm() -> None:
|
||
"""An out-and-back walk recrosses links the other way only — no dialog, no nag."""
|
||
screen, session = _trace_screen(mode="path")
|
||
screen._path_spec = "3d,f2,3d"
|
||
screen._index = screen._actions.index("trace")
|
||
screen.handle("enter")
|
||
await screen._worker
|
||
assert session.dialogs == []
|
||
assert len(screen._traces) == 1
|
||
|
||
|
||
# --- the new-record dialog -----------------------------------------------------------
|
||
|
||
|
||
async def test_record_run_floats_the_new_record_dialog() -> None:
|
||
"""A run that placed floats one dialog; Close (the default) keeps the screen up."""
|
||
|
||
async def trace(path_spec, on_trace): # noqa: ANN001
|
||
on_trace(_trace(5.0), ["Most nodes — 4 nodes"])
|
||
|
||
screen, session = _trace_screen(trace=trace)
|
||
screen.future = asyncio.get_running_loop().create_future()
|
||
screen.start_trace()
|
||
await screen._worker
|
||
await _settle(screen) # the announcement task floats after the run settles
|
||
prompt, buttons, kwargs = session.dialogs[0]
|
||
assert "Most nodes — 4 nodes" in prompt.plain
|
||
assert [label for label, _ in buttons] == ["Trophy case", "Close"]
|
||
assert kwargs["default"] == 1 # Close is the default: Enter simply dismisses
|
||
assert kwargs["title"] == "New record"
|
||
assert not screen.future.done() # Close/Esc: the screen stays up
|
||
assert screen._run_placed == {} # announced once, not again on the next run
|
||
|
||
|
||
async def test_record_dialog_trophy_case_opens_over_the_trace_it_was_earned_on() -> None:
|
||
"""Choosing Trophy case opens it *above* the trace screen, which stays up underneath.
|
||
|
||
It used to resolve the whole screen with a sentinel so the trace unwound away first and
|
||
the reader landed on the main menu. Navigation is a strict stack now: a sub-view nests,
|
||
Esc from the trophy case is one pop back onto the trace that earned the record, and ^W is
|
||
what leaves the whole excursion.
|
||
"""
|
||
opened: list[str] = []
|
||
|
||
async def trace(path_spec, on_trace): # noqa: ANN001
|
||
on_trace(_trace(5.0), ["Most nodes — 4 nodes", "Longest distance — 12.4 km"])
|
||
|
||
async def open_trophy_case() -> None:
|
||
opened.append("trophy case")
|
||
|
||
screen, session = _trace_screen(
|
||
trace=trace, samples=2, open_trophy_case=open_trophy_case
|
||
)
|
||
session.dialog_answer = OPEN_TROPHY_CASE
|
||
screen.future = asyncio.get_running_loop().create_future()
|
||
screen.start_trace()
|
||
await screen._worker
|
||
await _settle(screen)
|
||
_prompt, _buttons, kwargs = session.dialogs[0]
|
||
assert kwargs["title"] == "2 new records" # one dialog for the whole run
|
||
assert len(session.dialogs) == 1 # however many samples scored
|
||
assert opened == ["trophy case"]
|
||
assert not screen.future.done(), "the trace screen stays up under the trophy case"
|
||
|
||
|
||
async def test_escaping_mid_run_skips_the_record_dialog() -> None:
|
||
"""Esc while records are banked leaves quietly — no popup chases the user out."""
|
||
release = asyncio.Event()
|
||
|
||
async def trace(path_spec, on_trace): # noqa: ANN001
|
||
on_trace(_trace(5.0), ["Most nodes — 4 nodes"])
|
||
await release.wait()
|
||
|
||
screen, session = _trace_screen(trace=trace, samples=2)
|
||
screen.future = asyncio.get_running_loop().create_future()
|
||
screen.start_trace()
|
||
await asyncio.sleep(0)
|
||
screen.handle("escape") # resolves the screen and cancels the run
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await screen._worker
|
||
for _ in range(3):
|
||
await asyncio.sleep(0)
|
||
assert session.dialogs == []
|
||
|
||
|
||
def test_planned_route_dims_only_the_mirrored_return_leg() -> None:
|
||
"""A palindromic target-mode spec dims its second half; hand walks never dim.
|
||
|
||
Both ends are ours, so both go bare: the line opens and closes on a lone arrow.
|
||
"""
|
||
screen, _ = _trace_screen()
|
||
|
||
def faint_cells(text) -> int: # noqa: ANN001
|
||
return sum(
|
||
span.end - span.start for span in text.spans if "faint" in str(span.style)
|
||
)
|
||
|
||
screen._path_spec = "3d,f2,3d" # symmetric boomerang: the mirror is dimmed
|
||
symmetric = screen._planned_route().text()
|
||
assert symmetric.plain == "★ → 3d → f2 → 3d → ★"
|
||
screen._path_spec = "3d,f2,27" # a stale hand walk: every hop is the user's
|
||
custom = screen._planned_route().text()
|
||
assert custom.plain == "★ → 3d → f2 → 27 → ★"
|
||
assert faint_cells(symmetric) > faint_cells(custom)
|
||
|
||
# In path mode even a there-and-back-the-same-way walk is fully hand-composed,
|
||
# so a palindrome must NOT read as "not yours to compose".
|
||
walk, _ = _trace_screen(mode="path")
|
||
walk._path_spec = "3d,f2,3d"
|
||
assert faint_cells(walk._planned_route().text()) == faint_cells(custom)
|
||
|
||
|
||
def test_route_lane_wraps_at_hop_boundaries_under_its_own_column() -> None:
|
||
"""A route too wide for the lane breaks between hops, never inside one.
|
||
|
||
Every continuation hangs under the value column (never back at column zero), the
|
||
line it continues from ends on the ``→`` cue, and every name survives whole — the
|
||
lane names nodes and leaves the hex to the ``path`` lane below it.
|
||
"""
|
||
names = {"3d": "YUL-Cartierville", "f2": "Mile-End-Rooftop", "27": "Beaubien-Sud"}
|
||
screen, _ = _trace_screen(mode="path")
|
||
screen._resolve = lambda h: names.get(h, h)
|
||
screen._path_spec = "3d,f2,27"
|
||
lines = [line.plain for line in screen._route_value(None, 60)]
|
||
assert len(lines) > 1 # it really did wrap at this width
|
||
assert all(len(line) <= 60 for line in lines)
|
||
assert all(line.startswith(" " * 7) for line in lines[1:]) # hanging, not column 0
|
||
assert all(line.rstrip().endswith("→") for line in lines[:-1])
|
||
for hop, name in names.items(): # names whole, and no hash trailing any of them
|
||
assert any(name in line for line in lines)
|
||
assert all(f"({hop})" not in line for line in lines)
|
||
|
||
|
||
def test_path_lane_breaks_the_wire_spec_after_a_comma() -> None:
|
||
"""The spec lane stays the verbatim wire string, folding only at its commas."""
|
||
screen, _ = _trace_screen(mode="path")
|
||
screen._path_spec = "3d63ab99,7f21cd01,27aa1122,f2c20099"
|
||
lines = [line.plain for line in screen._path_value(None, 44)]
|
||
assert len(lines) > 1
|
||
assert all(len(line) <= 44 for line in lines)
|
||
assert lines[0].endswith(",") # the comma is the cue, not an arrow
|
||
spec = "".join(line.strip() for line in lines)
|
||
assert spec.startswith(screen._path_spec) # character for character, hop count after
|
||
|
||
|
||
def test_summary_appends_the_displayed_hop_count() -> None:
|
||
"""The path row ends with how many nodes the displayed route passes through."""
|
||
screen, _ = _trace_screen()
|
||
screen._path_spec = "3d,f2,3d"
|
||
assert "· 3 hops" in _plain(screen.render_body(100))
|
||
screen._on_trace(_trace(5.0, 2.0)) # a live 2-hop route now outranks the plan
|
||
assert "· 2 hops" in _plain(screen.render_body(100))
|
||
|
||
|
||
def test_auto_resolved_route_renders_with_its_provenance() -> None:
|
||
"""With no composed path, the auto route shows as the plan, labelled with its source.
|
||
|
||
What the screen draws is exactly what Trace will put on the air (both read the
|
||
same resolver), so the user can see the forced boomerang — and where it came
|
||
from — before committing a transmission.
|
||
"""
|
||
screen, _ = _trace_screen(
|
||
auto_spec=lambda: "3d63,f2c2,aabb,f2c2,3d63", auto_source="last trace · Jul 09 14:32"
|
||
)
|
||
plan = screen._planned_route().text().plain
|
||
assert "→ 3d63 → f2c2 → aabb → f2c2 → 3d63 →" in plan
|
||
# The provenance hangs on its own line under the route, not inside the path itself.
|
||
route = "\n".join(line.plain for line in screen._route_value(None, 100))
|
||
assert route.endswith("(auto · last trace · Jul 09 14:32)")
|
||
body = _plain(screen.render_body(100))
|
||
assert "auto · last trace · Jul 09 14:32" in body # the summary's path row
|
||
assert "· 5 hops" in body
|
||
|
||
|
||
def test_composed_path_outranks_the_auto_route() -> None:
|
||
"""A hand-composed spec replaces the auto plan everywhere — display and wire."""
|
||
screen, _ = _trace_screen(auto_spec=lambda: "aabb", auto_source="device route")
|
||
screen._path_spec = "3d63,aabb,3d63"
|
||
plan = screen._planned_route().text().plain
|
||
assert "→ 3d63 → aabb → 3d63 →" in plan
|
||
assert "(auto ·" not in plan
|
||
assert screen._effective_spec() == ("3d63,aabb,3d63", False)
|
||
|
||
|
||
def test_unaddressable_target_reads_as_path_less_auto() -> None:
|
||
"""With nothing to force (no target hash), the summary says so instead of lying."""
|
||
screen, _ = _trace_screen() # auto_spec resolves ""
|
||
assert "auto — path-less (unknown target)" in _plain(screen.render_body(100))
|
||
|
||
|
||
def test_trace_log_section_hidden_until_there_is_something_to_log() -> None:
|
||
"""Idle with no traces, the Traces heading (and its old hint) don't render."""
|
||
screen, _ = _trace_screen()
|
||
assert "Traces" not in _plain(screen.render_body(100))
|
||
|
||
|
||
async def test_trace_screen_path_mode_gates_trace_and_drops_explore() -> None:
|
||
"""Path mode: no Explore row, and Trace stays inert until a path exists."""
|
||
screen, _ = _trace_screen(mode="path")
|
||
body = _plain(screen.render_body(100))
|
||
assert "Explore paths" not in body
|
||
assert "Trace — compose a path first" in body
|
||
assert "none — compose a path" in body
|
||
screen.handle("enter") # the cursor opens on Trace, but there is nothing to walk
|
||
assert not screen._running and screen._worker is None
|
||
screen._path_spec = "3d,f2"
|
||
assert "Trace — one transmission" in _plain(screen.render_body(100))
|
||
screen.handle("enter")
|
||
assert screen._running
|
||
await screen._worker
|
||
assert len(screen._traces) == 1
|
||
|
||
|
||
async def test_trace_screen_path_mode_arms_from_the_previous_walk() -> None:
|
||
"""The last stored walk isn't just displayed — it's the path Enter walks.
|
||
|
||
Path mode's auto route (the previous successful walk) must arm Trace exactly
|
||
like a composed spec, and read as the plan with its provenance, so the screen
|
||
never shows a route it then refuses to trace.
|
||
"""
|
||
screen, _ = _trace_screen(
|
||
mode="path", auto_spec=lambda: "3d,f2", auto_source="last walk · Jul 09 14:32"
|
||
)
|
||
body = _plain(screen.render_body(100))
|
||
assert "Trace — one transmission" in body # armed, not "compose a path first"
|
||
assert "auto · last walk · Jul 09 14:32" in body # the summary's path row
|
||
plan = screen._planned_route().text().plain
|
||
assert "→ 3d → f2 →" in plan
|
||
assert "(auto · last walk · Jul 09 14:32)" in body # hanging under the route
|
||
screen.handle("enter") # the cursor opens on Trace
|
||
assert screen._running
|
||
await screen._worker
|
||
assert len(screen._traces) == 1
|
||
|
||
|
||
def test_reverse_is_a_path_mode_only_action() -> None:
|
||
"""A target route is a symmetric boomerang, so only path mode offers Reverse.
|
||
|
||
In path mode the row sits in the build-path group, between Compose and the
|
||
trace-settings, so the whole "define the path" cluster reads together.
|
||
"""
|
||
target, _ = _trace_screen(mode="target")
|
||
assert "reverse" not in target._actions
|
||
assert "Reverse path" not in _plain(target.render_body(100))
|
||
|
||
walk, _ = _trace_screen(mode="path")
|
||
assert "reverse" in walk._actions
|
||
body = _plain(walk.render_body(100))
|
||
assert body.index("Compose path") < body.index("Reverse path") < body.index("Path width")
|
||
|
||
|
||
async def test_reverse_flips_a_path_walk_and_restarts_the_run() -> None:
|
||
"""Reverse flips the hop order and clears the forward run, like any path change."""
|
||
screen, _ = _trace_screen(mode="path")
|
||
screen._path_spec = "3d,f2,27"
|
||
screen.start_trace()
|
||
await screen._worker
|
||
assert screen._traces # a forward reading stands
|
||
screen._index = screen._actions.index("reverse")
|
||
screen.handle("enter")
|
||
assert screen._path_spec == "27,f2,3d" # end-for-end
|
||
assert screen._traces == [] # the forward aggregates cleared
|
||
assert screen._total_traces == 1 # the session count survives the restart
|
||
assert "→ 27 → f2 → 3d →" in screen._planned_route().text().plain
|
||
|
||
|
||
def test_reverse_adopts_and_flips_the_visible_auto_walk() -> None:
|
||
"""With only the auto walk showing, Reverse pins its mirror as the path to walk."""
|
||
screen, _ = _trace_screen(
|
||
mode="path", auto_spec=lambda: "3d,f2", auto_source="last walk"
|
||
)
|
||
assert screen._path_spec == "" # nothing composed yet; the plan is auto
|
||
screen._index = screen._actions.index("reverse")
|
||
screen.handle("enter")
|
||
assert screen._path_spec == "f2,3d" # the shown auto walk, flipped and pinned
|
||
assert "→ f2 → 3d →" in screen._planned_route().text().plain
|
||
|
||
|
||
def test_reverse_is_inert_without_a_reversible_path() -> None:
|
||
"""No path (or a single hop, its own mirror) leaves nothing to flip."""
|
||
screen, _ = _trace_screen(mode="path") # no spec, no auto walk
|
||
assert "Reverse path — compose a path first" in _plain(screen.render_body(100))
|
||
screen._index = screen._actions.index("reverse")
|
||
screen.handle("enter")
|
||
assert screen._path_spec == ""
|
||
screen._path_spec = "3d" # a lone hop reverses to itself
|
||
screen.handle("enter")
|
||
assert screen._path_spec == "3d"
|
||
|
||
|
||
async def test_adopting_a_new_path_restarts_the_measurement() -> None:
|
||
"""A different spec clears the aggregates and log, like a freshly opened screen.
|
||
|
||
The old numbers described the old route; only the screen-lifetime total (what
|
||
the owner reports as the session's trace count) survives the reset.
|
||
"""
|
||
|
||
async def compose(current: str): # noqa: ANN001
|
||
return "3d,f2,3d"
|
||
|
||
screen, _ = _trace_screen(compose_path=compose)
|
||
screen.start_trace()
|
||
await screen._worker
|
||
assert "Traces" in _plain(screen.render_body(100))
|
||
for _ in range(4):
|
||
screen.handle("up") # Trace → Sample count → Path width → Explore → Compose
|
||
screen.handle("enter")
|
||
await asyncio.sleep(0)
|
||
assert screen._path_spec == "3d,f2,3d"
|
||
assert screen._traces == []
|
||
body = _plain(screen.render_body(100))
|
||
assert "Traces" not in body and "Per-hop medians" not in body
|
||
assert screen._total_traces == 1 # the session count is not rewritten
|
||
|
||
|
||
async def test_keeping_the_same_path_keeps_the_stats() -> None:
|
||
"""Flows that resolve None or the unchanged spec never touch the accumulated run."""
|
||
|
||
async def keep(current: str): # noqa: ANN001
|
||
return current # e.g. the width dialog re-rendering to an identical spec
|
||
|
||
screen, _ = _trace_screen(compose_path=keep)
|
||
screen._path_spec = "3d,f2,3d"
|
||
screen.start_trace()
|
||
await screen._worker
|
||
for _ in range(4):
|
||
screen.handle("up") # Trace → Sample count → Path width → Explore → Compose
|
||
screen.handle("enter")
|
||
await asyncio.sleep(0)
|
||
assert screen._path_spec == "3d,f2,3d"
|
||
assert len(screen._traces) == 1
|
||
|
||
|
||
async def test_trace_screen_opens_idle_until_enter() -> None:
|
||
"""Selecting a target must never transmit by itself: nothing flies until Enter."""
|
||
screen, session = _trace_screen()
|
||
assert not screen._running and screen._worker is None
|
||
assert "press Enter to trace" in _plain(screen.render_body(100))
|
||
screen.handle("enter")
|
||
assert screen._running
|
||
await screen._worker
|
||
assert session.stack == [] # the tracing dialog was popped with the trace
|
||
|
||
|
||
async def test_trace_screen_floats_the_tracing_dialog() -> None:
|
||
"""A trace pushes the abortable dialog for its duration and pops it however it ends."""
|
||
release = asyncio.Event()
|
||
|
||
async def trace(path_spec, on_trace): # noqa: ANN001
|
||
on_trace(_trace(5.0))
|
||
await release.wait()
|
||
|
||
screen, session = _trace_screen(trace=trace)
|
||
screen.start_trace()
|
||
await asyncio.sleep(0)
|
||
assert len(session.stack) == 1
|
||
dialog = session.stack[0]
|
||
assert dialog.last is not None # the landed reply echoes on the dialog
|
||
body = _plain(dialog.render_body(60))
|
||
assert "Abort" in body
|
||
release.set()
|
||
await screen._worker
|
||
assert session.stack == []
|
||
|
||
|
||
async def test_tracing_dialog_abort_cancels_the_trace() -> None:
|
||
"""Enter/Esc on the tracing dialog cancels the in-flight trace via the screen."""
|
||
release = asyncio.Event()
|
||
|
||
async def trace(path_spec, on_trace): # noqa: ANN001
|
||
await release.wait()
|
||
|
||
screen, session = _trace_screen(trace=trace)
|
||
screen.start_trace()
|
||
await asyncio.sleep(0)
|
||
session.stack[0].handle("escape")
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await screen._worker
|
||
assert session.stack == []
|
||
assert not screen._running
|
||
|
||
|
||
async def test_trace_screen_escape_cancels_the_inflight_trace() -> None:
|
||
"""Esc resolves the screen and cancels a trace that is still measuring."""
|
||
release = asyncio.Event()
|
||
|
||
async def trace(path_spec, on_trace): # noqa: ANN001
|
||
await release.wait()
|
||
|
||
screen, _ = _trace_screen(trace=trace)
|
||
screen.start_trace()
|
||
screen.future = asyncio.get_running_loop().create_future()
|
||
screen.handle("escape")
|
||
assert screen.future.result() is None
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await screen._worker
|
||
|
||
|
||
# --- TxSweepScreen -----------------------------------------------------------------
|
||
|
||
|
||
def _level(tx: int, snr: float | None, successes: int = 2, samples: int = 2) -> TxLevelResult:
|
||
return TxLevelResult(
|
||
tx_power=tx,
|
||
samples=samples,
|
||
successes=successes,
|
||
target_snr=snr,
|
||
score=snr if snr is not None else float("-inf"),
|
||
stats=TraceStats.from_traces("Alice", []),
|
||
)
|
||
|
||
|
||
def _sweep_screen(apply_winner=None, run_sweep=None) -> tuple[TxSweepScreen, _FakeSession]:
|
||
session = _FakeSession()
|
||
screen = TxSweepScreen(
|
||
admin_label="Repeater",
|
||
target_label="Alice",
|
||
device_label="us",
|
||
device_hash="00" * 32,
|
||
resolve=lambda h: h,
|
||
session=session,
|
||
tx_min=12,
|
||
tx_max=28,
|
||
step=3,
|
||
samples=3,
|
||
run_sweep=run_sweep or (lambda: None),
|
||
apply_winner=apply_winner or (lambda: None),
|
||
)
|
||
return screen, session
|
||
|
||
|
||
def _result(best_tx: int = 19, best_snr: float | None = 8.8) -> TxOptResult:
|
||
return TxOptResult(
|
||
target="Alice",
|
||
admin_node="Repeater",
|
||
path="a1b2,d4e5",
|
||
original_tx=20,
|
||
best_tx=best_tx,
|
||
best_snr=best_snr,
|
||
best_success_rate=1.0,
|
||
applied=False,
|
||
levels=[_level(best_tx, best_snr)],
|
||
)
|
||
|
||
|
||
def test_sweep_screen_opens_armed_and_idle_on_sweep() -> None:
|
||
"""The screen opens with the cursor on Sweep and nothing transmitted."""
|
||
ran: list[bool] = []
|
||
screen, _ = _sweep_screen(run_sweep=lambda: ran.append(True))
|
||
body = _plain(screen.render_body(100))
|
||
assert "Sweep — up to" in body and "Route — direct to Repeater" in body
|
||
assert not screen.running and ran == []
|
||
screen.handle("enter") # the cursor opens on Sweep
|
||
assert ran == [True]
|
||
|
||
|
||
def test_sweep_screen_quotes_the_transmission_budget() -> None:
|
||
"""The Sweep row's worst case tracks the window, step, and samples."""
|
||
screen, _ = _sweep_screen()
|
||
# 12–28 step 3 → 7 coarse levels (28 appended); refine ≤ 4; verify 1 → 12 × 3.
|
||
assert screen.estimated_traces() == 36
|
||
screen.samples = 1
|
||
screen.step = 1
|
||
# Every level measured up front (17), no refine grid, one verify batch.
|
||
assert screen.estimated_traces() == 18
|
||
assert "up to 18 paced transmissions" in _plain(screen.render_body(100))
|
||
|
||
|
||
def test_sweep_screen_stars_the_running_best() -> None:
|
||
"""Each landed level renders ascending by TX with the current best starred."""
|
||
screen, _ = _sweep_screen()
|
||
screen.on_phase("coarse")
|
||
screen.on_level(1, 7, _level(12, -2.0))
|
||
screen.on_level(2, 7, _level(18, 7.5))
|
||
assert screen.phase_label() == "coarse sweep · level 2/7"
|
||
# The route lane wears ★ for our own ends, so the star that marks the winner is the
|
||
# one in the levels table — a row that also carries the level's reading.
|
||
starred = next(
|
||
line for line in _plain(screen.render_body(100)).splitlines()
|
||
if "★" in line and "dB" in line
|
||
)
|
||
assert "18" in starred
|
||
|
||
|
||
def test_sweep_screen_shows_failed_levels_distinctly() -> None:
|
||
"""A level nothing got through at reads as a no-reply row, not an empty bar."""
|
||
screen, _ = _sweep_screen()
|
||
screen.on_level(1, 7, _level(28, None, successes=0))
|
||
assert "✗ no reply" in _plain(screen.render_body(100))
|
||
|
||
|
||
def test_sweep_screen_completion_offers_the_winner() -> None:
|
||
"""Completion lands the cursor on the new Apply row; Enter re-offers the dialog."""
|
||
offered: list[bool] = []
|
||
screen, _ = _sweep_screen(apply_winner=lambda: offered.append(True))
|
||
assert "apply" not in screen._actions # nothing to apply while measuring
|
||
screen.complete(_result())
|
||
body = _plain(screen.render_body(100))
|
||
assert "best" in body and "TX 19" in body
|
||
assert "Apply winner — set TX 19 on Repeater" in body
|
||
screen.handle("enter") # the cursor parked itself on Apply
|
||
assert offered == [True]
|
||
|
||
|
||
def test_sweep_screen_apply_updates_status_and_retires_the_row() -> None:
|
||
"""Marking the winner applied flips the status line and removes the Apply row."""
|
||
offered: list[bool] = []
|
||
screen, _ = _sweep_screen(apply_winner=lambda: offered.append(True))
|
||
screen.complete(_result())
|
||
screen.mark_applied()
|
||
body = _plain(screen.render_body(100))
|
||
assert "✓ TX 19 set on Repeater" in body
|
||
assert "Apply winner" not in body
|
||
screen.handle("enter")
|
||
assert offered == []
|
||
|
||
|
||
def test_sweep_screen_no_result_never_offers_apply() -> None:
|
||
"""A sweep where nothing got through warns and never grows an Apply row."""
|
||
screen, _ = _sweep_screen()
|
||
screen.complete(_result(best_snr=None))
|
||
body = _plain(screen.render_body(100))
|
||
assert "no traces reached" in body
|
||
assert "Apply winner" not in body
|
||
|
||
|
||
def test_sweep_screen_failure_keeps_measured_levels_on_screen() -> None:
|
||
"""A mid-sweep error is reported while the levels already measured stay visible."""
|
||
screen, _ = _sweep_screen()
|
||
screen.on_level(1, 7, _level(12, -2.0))
|
||
screen.fail("link lost")
|
||
body = _plain(screen.render_body(100))
|
||
assert "sweep failed: link lost" in body
|
||
assert "-2.0" in body
|
||
|
||
|
||
def test_sweep_screen_new_sweep_clears_the_old_evidence() -> None:
|
||
"""Starting another sweep is a new measurement: chart and outcome reset."""
|
||
screen, _ = _sweep_screen()
|
||
screen.on_level(1, 7, _level(12, -2.0))
|
||
screen.complete(_result())
|
||
|
||
async def go() -> None:
|
||
task = asyncio.ensure_future(asyncio.sleep(0))
|
||
screen.sweep_started(task)
|
||
assert screen.running
|
||
body = _plain(screen.render_body(100))
|
||
assert "TX 19" not in body and "-2.0" not in body
|
||
await task
|
||
|
||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(go())
|
||
|
||
|
||
def test_sweep_screen_escape_resolves() -> None:
|
||
"""Esc resolves the screen (the controller then cancels and restores)."""
|
||
screen, _ = _sweep_screen()
|
||
|
||
class _Fut:
|
||
def __init__(self) -> None:
|
||
self.value = None
|
||
self._done = False
|
||
|
||
def done(self) -> bool:
|
||
return self._done
|
||
|
||
def set_result(self, value) -> None: # noqa: ANN001
|
||
self.value = value
|
||
self._done = True
|
||
|
||
screen.future = _Fut()
|
||
screen.handle("escape")
|
||
assert screen.future.done()
|
||
|
||
|
||
def test_parse_tx_range_clamps_and_rejects() -> None:
|
||
"""The typed window accepts two ordered numbers and clamps to the remote range."""
|
||
from meshterm.ui.tx_screen import parse_tx_range
|
||
|
||
assert parse_tx_range("14-24") == (14, 24)
|
||
assert parse_tx_range("14 24") == (14, 24)
|
||
assert parse_tx_range("2-99") == (12, 28) # clamped to the firmware window
|
||
assert parse_tx_range("24-14") is None
|
||
assert parse_tx_range("banana") is None
|
||
|
||
|
||
# --- explore-paths scenario rows --------------------------------------------------
|
||
|
||
_US = "aaaaaaaaaaaa"
|
||
_HUB = Contact(name="Hub", public_key="3d63c6429436" + "0" * 52, key_prefix="3d63c6429436")
|
||
_FAR = Contact(name="Far", public_key="f2c24f54551e" + "0" * 52, key_prefix="f2c24f54551e")
|
||
|
||
|
||
def _scenario_topo() -> object:
|
||
return build_topology(
|
||
self_id=_US + "0" * 52, contacts=[_HUB, _FAR],
|
||
trace_paths=[], packet_paths=[], neighbour_links=[],
|
||
)
|
||
|
||
|
||
def test_scenario_path_leads_and_ends_with_us_and_the_target() -> None:
|
||
"""The pathline draws the whole boomerang leg, not just the stored intermediate
|
||
hops — our own end bare, since every candidate starts from us."""
|
||
topo = _scenario_topo()
|
||
scenario = PathScenario(
|
||
label="observed path", hops=("3d63c6429436",), source="observed", score=1.0,
|
||
)
|
||
text = _scenario_path(
|
||
scenario, topo, "f2c24f54551e", device_label="YUL-Me", width_bytes=1
|
||
)
|
||
assert text.plain == "★ → Hub → Far"
|
||
|
||
|
||
def test_scenario_path_direct_scenario_still_names_both_endpoints() -> None:
|
||
"""A direct (no-repeaters) scenario's pathline is just our star and the target."""
|
||
topo = _scenario_topo()
|
||
scenario = PathScenario(label="direct", hops=(), source="direct", score=0.0)
|
||
text = _scenario_path(
|
||
scenario, topo, "f2c24f54551e", device_label="YUL-Me", width_bytes=1
|
||
)
|
||
assert text.plain == "★ → Far"
|
||
|
||
|
||
def test_scenario_path_cuts_a_long_candidate_rather_than_eliding_its_middle() -> None:
|
||
"""Every row is cut the way the highlighted one is at shift zero — no ``⋯`` rescue.
|
||
|
||
The middle-elide exists to save a route's two endpoints, and here both endpoints are
|
||
the same two on every row by construction (our own ``★``, the one target the screen is
|
||
about), so it would spend cells on what the reader knows and take them off the
|
||
candidates' *front* — the only part that differs. Cutting also stops the row being
|
||
redrawn the moment the cursor lands on it (JP, 2026-08-10).
|
||
"""
|
||
topo = _scenario_topo()
|
||
scenario = PathScenario(
|
||
label="observed path", hops=("3d63c6429436",), source="observed", score=1.0,
|
||
)
|
||
full = _scenario_path(
|
||
scenario, topo, "f2c24f54551e", device_label="YUL-Me", width_bytes=1
|
||
)
|
||
narrow = _scenario_path(
|
||
scenario, topo, "f2c24f54551e", device_label="YUL-Me", width_bytes=1,
|
||
width=full.cell_len - 3,
|
||
)
|
||
assert narrow.cell_len <= full.cell_len - 3
|
||
assert "⋯" not in narrow.plain # nothing elided out of the middle…
|
||
assert narrow.plain.startswith("★ → Hub") # …the head is intact, the tail is what went
|
||
|
||
|
||
def test_scenario_detail_leads_with_the_hop_count() -> None:
|
||
"""The stats line under a candidate opens with how long the route is."""
|
||
scenario = PathScenario(
|
||
label="observed path", hops=("3d63c6429436", "f2c24f54551e"), source="observed",
|
||
score=1.0, weakest_snr=-4.0, samples=2,
|
||
)
|
||
assert _scenario_detail(scenario).plain.startswith("2 hops · ")
|
||
# The direct shot's provenance tag *was* this same word, so the atom absorbs it rather
|
||
# than the row reading "direct · direct".
|
||
direct = PathScenario(label="direct", hops=(), source="direct", score=0.0, samples=1)
|
||
assert direct.label == "direct" and _scenario_detail(direct).plain.count("direct") == 1
|
||
|
||
|
||
def test_scenario_detail_carries_provenance_snr_and_samples() -> None:
|
||
"""The device/direct provenance tag, weakest SNR, and sample count all show, in order."""
|
||
scenario = PathScenario(
|
||
label="device route", hops=("3d63c6429436",), source="device", score=2.0,
|
||
weakest_snr=-6.0, samples=3,
|
||
)
|
||
detail = _scenario_detail(scenario)
|
||
assert "device route" in detail.plain
|
||
assert "weakest -6.0 dB" in detail.plain
|
||
assert "3×" in detail.plain
|
||
|
||
|
||
def test_scenario_detail_observed_carries_no_provenance_tag() -> None:
|
||
"""An observed candidate's detail line skips the tag — the route itself is the point."""
|
||
scenario = PathScenario(
|
||
label="observed path", hops=("3d63c6429436",), source="observed", score=1.0,
|
||
weakest_snr=-4.0, samples=2,
|
||
)
|
||
detail = _scenario_detail(scenario)
|
||
assert "observed" not in detail.plain
|
||
assert "weakest -4.0 dB" in detail.plain
|
||
|
||
|
||
def test_scenario_detail_unscored_scenario_reads_unobserved() -> None:
|
||
"""A scenario with no evidence at all (score 0, no samples) reads plainly unobserved."""
|
||
scenario = PathScenario(label="direct", hops=(), source="direct", score=0.0)
|
||
detail = _scenario_detail(scenario)
|
||
assert "direct" in detail.plain
|
||
assert "unobserved" in detail.plain
|