Files
MeshTerm/tests/test_repeater_admin.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

382 lines
15 KiB
Python

"""Tests for repeater admin.
The settings catalog, the per-node store, the simulated remote CLI, and the
command-line screen's readline behavior.
"""
from __future__ import annotations
import asyncio
import io
from pathlib import Path
import pytest
from rich.console import Console
from meshterm.context import AppContext
from meshterm.core.admin_store import AdminStore
from meshterm.core.config import Settings
from meshterm.core.connection import MockDevice
from meshterm.core.device_store import DeviceStore
from meshterm.core.models import Contact
from meshterm.core.remote_config import (
REPEATER_SETTINGS,
get_setting,
known_commands,
parse_reply_value,
validate_value,
)
from meshterm.core.remote_store import HISTORY_CAP, RemoteStore
from meshterm.persistence.repository import Repository
from meshterm.ui.remote_cli import RemoteCliScreen
from meshterm.ui.repeater_admin import open_repeater_admin
from meshterm.ui.surface import TuiUi
from meshterm.ui.tui.prompt import TextScreen
from meshterm.ui.tui.screen import CANCEL
from meshterm.ui.tui.select import SelectScreen
from meshterm.ui.tui.session import TuiSession
NODE = Contact(name="Yagi-Repeater", public_key="a1" * 32, key_prefix="a1b2c3d4")
class _FakeSession:
def __init__(self) -> None:
self.repaints = 0
def invalidate(self) -> None:
self.repaints += 1
# --- the catalog ----------------------------------------------------------------------
def test_catalog_carries_the_repeater_only_knobs() -> None:
"""TX delay and Direct TX delay — the reason this feature exists — are first-class."""
txdelay = get_setting("txdelay")
direct = get_setting("direct.txdelay")
assert txdelay is not None and txdelay.set_command("5") == "set txdelay 5"
assert direct is not None and direct.get_command == "get direct.txdelay"
assert any(s.key == "af" for s in REPEATER_SETTINGS)
assert any(s.key == "advert.interval" for s in REPEATER_SETTINGS)
def test_parse_reply_value_survives_terse_and_verbose_firmware() -> None:
"""Numbers are extracted from any phrasing; errors parse as unknown, not values."""
tx = get_setting("tx")
assert parse_reply_value(tx, "20") == "20"
assert parse_reply_value(tx, "tx: 20") == "20"
assert parse_reply_value(tx, "TX power = 20 dBm") == "20"
assert parse_reply_value(tx, "ERR: unknown config: tx") is None
assert parse_reply_value(tx, None) is None
name = get_setting("name")
assert parse_reply_value(name, "> Yagi") == "Yagi"
assert parse_reply_value(name, "name: Yagi") == "Yagi"
repeat = get_setting("repeat")
assert parse_reply_value(repeat, "on") == "on"
assert parse_reply_value(repeat, "repeat is off") == "off"
def test_validate_value_enforces_kind_and_bounds() -> None:
"""Prompt validation speaks in the setting's own terms."""
sf = get_setting("sf")
assert validate_value(sf, "9") is True
assert "≥ 7" in validate_value(sf, "3")
assert "number" in validate_value(sf, "fast")
assert validate_value(get_setting("repeat"), "maybe") == "Enter on or off."
assert validate_value(get_setting("name"), " ") == "Enter a value."
def test_known_commands_cover_catalog_and_verbs() -> None:
"""The CLI completions include every catalog spelling plus the fixed verbs."""
commands = known_commands()
assert "get txdelay" in commands
assert "set direct.txdelay " in commands
assert "reboot" in commands and "ver" in commands
assert "get guest.password" not in commands # write-only stays uncompletable
# --- the store ------------------------------------------------------------------------
def test_remote_store_caches_settings_per_node(tmp_path: Path) -> None:
"""Values round-trip with their read stamps, keyed per node."""
store = RemoteStore(tmp_path / "remote.json")
store.remember_setting(NODE, "txdelay", "5")
cached = store.settings(NODE)
assert cached["txdelay"].value == "5"
assert cached["txdelay"].read_at is not None
other = Contact(name="Other", public_key="b2" * 32)
assert store.settings(other) == {}
def test_remote_store_history_dedupes_and_caps(tmp_path: Path) -> None:
"""History appends in order, skips adjacent repeats, and stays capped."""
store = RemoteStore(tmp_path / "remote.json")
store.append_history(NODE, "get tx")
store.append_history(NODE, "get tx") # adjacent repeat — no stutter on recall
store.append_history(NODE, "set tx 20")
assert store.history(NODE) == ["get tx", "set tx 20"]
for i in range(HISTORY_CAP + 20):
store.append_history(NODE, f"cmd {i}")
history = store.history(NODE)
assert len(history) == HISTORY_CAP and history[-1] == f"cmd {HISTORY_CAP + 19}"
# --- the simulated remote CLI ----------------------------------------------------------
async def test_mock_remote_cli_requires_login() -> None:
"""A stranger's command reads as a timeout (None), exactly like hardware."""
device = MockDevice()
await device.connect()
assert await device.send_remote_command(NODE, "get tx") is None
await device.disconnect()
async def test_mock_remote_cli_round_trips_settings() -> None:
"""get/set work for catalog keys; unknown keys answer with an error string."""
device = MockDevice()
await device.connect()
assert await device.admin_login(NODE, "admin")
assert await device.send_remote_command(NODE, "get txdelay") == "> 0"
assert await device.send_remote_command(NODE, "set txdelay 5") == "OK"
assert await device.send_remote_command(NODE, "get txdelay") == "> 5"
assert await device.send_remote_command(NODE, "set tx 22") == "OK"
assert await device.get_remote_tx_power(NODE) == 22 # one shared TX state
reply = await device.send_remote_command(NODE, "get nonsense")
assert reply is not None and "unknown" in reply.lower()
assert "simulator" in (await device.send_remote_command(NODE, "ver"))
await device.disconnect()
# --- the login flow: the password floats over the node picker, never a blank frame ----
@pytest.fixture()
def tui_ctx(tmp_path: Path) -> AppContext:
"""A mock-backed context wired to a headless TUI session (no prompt_toolkit app)."""
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "admin.db")
ctx = AppContext(
console=Console(file=io.StringIO()),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
mock=True,
)
ctx.ui = TuiUi(TuiSession())
yield ctx
ctx.repo.close()
async def _step_until(predicate, *, limit: int = 200):
"""Yield to the event loop until ``predicate()`` is truthy; return it (or its last value)."""
value = predicate()
for _ in range(limit):
if value:
return value
await asyncio.sleep(0)
value = predicate()
return value
def test_picker_rows_hue_each_name_by_its_own_key(tui_ctx) -> None:
"""Picker rows lead with the type glyph in *its* colour and hue the name by the key.
Regression: the row was built as ``Text(glyph, style=type_style)``, which made the
node-type colour the whole row's *base* style — so every repeater's name came out the
repeater violet and a section of them read as one node. The glyph now carries its type
style as a span and the name takes :func:`~meshterm.ui.theme.name_style`, like every
other list of nodes in the app.
"""
from meshterm.ui.admin_picker import admin_picker_rows
from meshterm.ui.theme import name_style
from meshterm.ui.widgets import _NODE_GLYPHS
contacts = [
Contact(name="Hub-North", public_key="a1" * 32, key_prefix="a1b2c3d4", node_type=2),
Contact(name="Hub-South", public_key="d4" * 32, key_prefix="d4c3b2a1", node_type=2),
]
rows, candidates = admin_picker_rows(tui_ctx, contacts)
assert [c.name for c in candidates] == ["Hub-North", "Hub-South"]
glyph, glyph_style = _NODE_GLYPHS[2]
titles = {str(row.value): row.title for row in rows if getattr(row, "value", None) is not None}
hues = set()
for contact in contacts:
title = titles[contact.name]
assert str(title.style) == "" # no base style to paint the whole row one colour
assert title.plain == f"{glyph} {contact.name}"
assert any(s.style == glyph_style and s.start == 0 for s in title.spans)
hue = name_style(contact.name, contact.public_key)
at = title.plain.index(contact.name)
assert any(s.style == hue and s.start <= at < s.end for s in title.spans)
hues.add(hue)
assert len(hues) == 2 # two repeaters, two identities — not one violet block
async def test_login_password_floats_over_the_node_picker(tui_ctx) -> None:
"""The admin-login password prompt floats over the picker itself, not a lookalike.
Regression: ``_login`` ran on an empty stack (the picker was popped when it returned),
so ``session.text`` pushed a *blank* base and the password box floated over an erased
frame. That was patched by redrawing a second, identical ``SelectScreen`` as a static
backdrop; the picker now simply never leaves the stack, so the base under the prompt is
the list the pick came from — the same object, still highlighted on the row the reader
chose — and Esc out of the prompt is one pop back onto it rather than out of the flow.
"""
ctx = tui_ctx
session = ctx.ui.session
pick_title = "Repeater admin — node to manage"
task = asyncio.ensure_future(open_repeater_admin(ctx))
try:
picker = await _step_until(
lambda: (
session.top
if isinstance(session.top, SelectScreen) and session.top.title == pick_title
else None
)
)
assert picker is not None, "the node picker never opened"
# Walk to the repeater's row and commit it, the way a reader does.
for _ in range(len(picker._items)):
current = picker._current_choice()
if current is not None and current.value == "Yagi-Repeater":
break
picker.handle("down")
picker.handle("enter")
prompt = await _step_until(
lambda: session._float_layers()[0] if session._has_float() else None
)
assert isinstance(prompt, TextScreen) # the password box floats
assert session._base_screen() is picker # the very list, not a redrawn stand-in
current = picker._current_choice() # still highlighted on the node being logged into
assert current is not None and current.value == "Yagi-Repeater"
prompt.resolve(CANCEL) # Esc — abandon the login…
again = await _step_until(lambda: session.top if session.top is picker else None)
assert again is picker, "…and land back on the list, one pop, place kept"
picker.handle("escape") # Esc again leaves the flow
result = await task
finally:
if not task.done():
task.cancel()
assert result is None # no node was ever administered
# --- the command-line screen -----------------------------------------------------------
def _cli(history=None, sent=None) -> RemoteCliScreen:
return RemoteCliScreen(
node_label="Yagi-Repeater",
history=list(history or []),
send=(sent.append if sent is not None else lambda c: None),
session=_FakeSession(),
)
def _type(screen: RemoteCliScreen, text: str) -> None:
for ch in text:
screen.handle("text", ch)
def test_cli_screen_sends_on_enter_and_parks_while_waiting() -> None:
"""Enter commits the buffer once; a command in flight blocks the next send."""
sent: list[str] = []
screen = _cli(sent=sent)
_type(screen, "get tx")
screen.handle("enter")
assert sent == ["get tx"]
screen.sent("get tx") # the owner echoes and parks the prompt
_type(screen, "ver")
screen.handle("enter")
assert sent == ["get tx"] # parked: nothing new goes out until the reply lands
screen.reply("20")
screen.handle("enter") # the typed-while-parked buffer is still there to send
assert sent == ["get tx", "ver"]
def test_cli_screen_history_recall_keeps_the_draft() -> None:
"""↑ walks back through history; ↓ past the newest restores the unsent draft."""
screen = _cli(history=["get tx", "set tx 20"])
_type(screen, "dra")
screen.handle("up")
assert screen._editor.text == "set tx 20"
screen.handle("up")
assert screen._editor.text == "get tx"
screen.handle("down")
screen.handle("down")
assert screen._editor.text == "dra" # the draft came back
def test_cli_screen_tab_completes_known_commands() -> None:
"""Tab adopts the first known command extending the typed prefix."""
screen = _cli()
_type(screen, "get txd")
screen.handle("tab")
assert screen._editor.text == "get txdelay"
def test_cli_screen_transcript_shows_exchange() -> None:
"""The transcript keeps the echoed command and its reply, prompt at the bottom."""
screen = _cli()
screen.sent("get txdelay")
screen.reply("> 5")
body = "\n".join(screen.render_body(80))
assert "get txdelay" in body and "> 5" in body
assert screen.cursor_line() == screen._prompt_line # the view follows the prompt
def test_cli_screen_timeout_note_frees_the_prompt() -> None:
"""A timeout lands as a muted note and the prompt accepts input again."""
screen = _cli()
screen.sent("advert")
assert screen.busy
screen.failed("no reply within 10 s")
assert not screen.busy
assert "no reply within 10 s" in "\n".join(screen.render_body(80))
# --- the editor menu's column header --------------------------------------------------
def test_admin_menu_pins_the_column_header_over_the_category() -> None:
"""Scrolled deep, the lane names stay overhead with the category heading under them."""
import re
from meshterm.ui.repeater_admin import _menu_items
from meshterm.ui.tui import frame
ansi = re.compile(r"\x1b\[[0-9;]*m")
title, items = _menu_items(NODE, {}, {})
screen = SelectScreen(title, items)
for _ in range(18): # down past the first categories
screen.handle("down")
visible, above, _below = frame._visible_slice(screen, screen.render_body(72), 10)
top = [ansi.sub("", row).strip() for row in visible[:2]]
assert top[0].startswith("SETTING") and top[0].endswith("DESCRIPTION")
assert top[1].startswith("──") # the category the highlighted row sits in
assert above is True
def test_admin_menu_header_abbreviates_rather_than_wrapping() -> None:
"""Too narrow for the whole line, the last label shortens — the header stays one row."""
from rich.cells import cell_len
from meshterm.ui.repeater_admin import _menu_items
_title, items = _menu_items(NODE, {}, {})
header = items[0]
assert header.pinned
full = header.text(100)
assert full.endswith("DESCRIPTION")
narrow = header.text(cell_len(full) - 2)
assert narrow.endswith("DESC") and "\n" not in narrow