Files
MeshTerm/tests/test_repeater_admin.py
T
Jean-Pierre MartineauandClaude Opus 5 f2a49c8e2d Editors keep their place too: rows change, the reader doesn't move
Six lists rebuilt themselves as a whole new screen every round, because their
content is data: an editor's rows carry a staged `current → new` value, its
title counts what is staged, the outbox loses the message it just sent, the
watchtower's log rewrites the row you acknowledged. Each approximated the cost
with `default=cursor`, which recovers only the cursor, and only while the row it
names still exists — the typed filter went every time.

SelectScreen.replace_items swaps the rows (and optionally the title) in place
instead. The place is kept by value, so the highlight lands back on its row
wherever that row moved to, and falls back to the same position in the list when
the row is gone entirely — which is what you want after deleting the row you
were sitting on. The filter rides along.

So the config editor, device actions, the channel manager, the courier outbox,
the watchtower and the repeater-admin editor all hold one screen for the whole
visit now, refreshed after each action. The courier's ticker is started once for
the visit rather than per round, and folds a flow's effect in immediately rather
than waiting up to a second for the next tick.

The admin node picker gains a `stay` form. Repeater admin used to draw a second,
identical SelectScreen as a static backdrop so the password prompt had something
behind it, then drop the list before the session; the real list stays up through
both now — so cancelling the password lands back on the node list rather than
bowing out of the flow, and leaving a node's admin session lands there too.
Administering two repeaters in a row is an Esc and a pick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:38:26 -04:00

379 lines
15 KiB
Python

"""Repeater-admin tests: 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="YUL-North", public_key="a1" * 32, key_prefix="a1b2c3d4", node_type=2),
Contact(name="YUL-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] == ["YUL-North", "YUL-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, wrap=False)
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