"""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