Two things a grouped list has to keep in view while it scrolls, and neither one was staying: the lane names that read every row, and the heading that says which group a row belongs to. The lanes come first. A column header means the same thing in every section, so it is not a section landmark at all — it pins for the *whole* list, above any heading, once it scrolls off (`Separator(pinned=True)`). It is drawn outside the body slice, so it stays exactly one row: too wide for the terminal it ellipsizes rather than wrapping and stealing a second reserved row from the content. And it can now size itself — a Separator title may be a callable taking the render width — so `column_header`/`Lane` lay each label over its own lane and hand back the shorter form (DESCRIPTION → DESC) only for the lanes that actually overrun, from the right, where the trailing lane is the only one with a cell to give. `lane_header` is that header for `lane_row`'s lanes, shared by the two editors so they head identical lanes identically. The headings were the other half, and they were pinning the wrong row. Every non-pinned separator was a candidate, and the rule takes the last one at or above the scroll — so what pinned was whatever muted line sat lowest under the heading. In the Trophy case that meant a discipline's *description* stuck to the top and the heading itself never did; at the foot of any list, the blank spacer above Back pinned a blank row. So heading-ness is explicit now (`Separator.heading`, conferred by `section_heading`, which is why every hand-rolled `── … ──` in the app — the main menu's categories, the chat picker's Channels/Direct, Trace path's candidate and ranked lists — now goes through it, and why three lanes headers that genuinely *are* their block's only landmark say so). A landmark is also no longer one line but a block: a heading plus the separators written immediately under it, before the section's first row. Prose written there is the section's preamble — the Trophy case's "what this discipline scores" — and it belongs overhead with the heading rather than scrolling away from the rows it explains. The block hands its rows over one at a time as they leave, so the heading pins while its description is still the top content row and the two pin together once both are gone: what is overhead always continues seamlessly into the body. It never takes more than half the viewport, giving rows up from the end, so the row that says *which* section this is is the last one dropped. The frame settles the scroll against however many rows come back rather than assuming one, and re-asks after sliding the window at the bottom — a heading among the dropped lines would otherwise vanish instead of pinning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
334 lines
13 KiB
Python
334 lines
13 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
|
|
|
|
|
|
async def test_login_password_floats_over_the_node_picker(tui_ctx) -> None:
|
|
"""The admin-login password prompt floats over the picker, not an erased background.
|
|
|
|
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. ``open_repeater_admin`` now redraws the node picker as a static backdrop and keeps
|
|
it pushed across the login, so the base under the floating prompt is the picker — the
|
|
picked node still highlighted — never a blank frame.
|
|
"""
|
|
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"
|
|
|
|
picker.resolve("Yagi-Repeater") # pick the repeater to administer
|
|
prompt = await _step_until(
|
|
lambda: session._float_layers()[0] if session._has_float() else None
|
|
)
|
|
assert isinstance(prompt, TextScreen) # the password box floats
|
|
|
|
base = session._base_screen()
|
|
assert isinstance(base, SelectScreen) and base.title == pick_title # the picker backdrop
|
|
assert base is not picker # a fresh redraw kept as the backdrop, not the popped picker
|
|
current = base._current_choice() # the picked node stays highlighted behind the prompt
|
|
assert current is not None and current.value == "Yagi-Repeater"
|
|
|
|
prompt.resolve(CANCEL) # Esc — abandon the login
|
|
result = await task
|
|
finally:
|
|
if not task.done():
|
|
task.cancel()
|
|
|
|
assert result is None # cancelling the password bows the flow out
|
|
|
|
|
|
# --- 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
|