Files
MeshTerm/tests/test_config_editor.py
T
Jean-Pierre MartineauandClaude Fable 5 f6306170ed Share card: render the QR in braille, one dot per module
A braille cell packs a 2x4 block of modules, so the code draws at half
the width and a quarter of the height of the half-block form (27x14 vs
53x27 for a contact URL) — small enough for the 40-column PicoCalc
panel. Simulated-camera decode tests pass with dot fills down to 50%,
since a phone's blur merges the dots into blobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:37:55 -04:00

669 lines
24 KiB
Python

"""Tests for the overhauled config editor: dialogs, staging, and the location picker.
The pure pieces (the contact share URL, coordinate parsing, the typed-confirmation
dialog, the map picker screen) are exercised directly; the editor's flows run end-to-end
against the :class:`MockDevice` simulator through a scripted fake UI surface that answers
each prompt from a queue.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Optional
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.device_store import DeviceStore
from meshterm.persistence.repository import Repository
from meshterm.ui.config_editor import (
_parse_coords,
_valid_coords,
contact_share_url,
device_actions,
edit_config,
send_advert,
)
from meshterm.ui.tui.prompt import TypedConfirmDialog
from meshterm.ui.tui.screen import CANCEL
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
def _plain(lines: list[str]) -> str:
"""Strip ANSI escapes and join rendered lines for content assertions."""
return _ANSI.sub("", "\n".join(lines))
class _Fut:
"""A minimal future stand-in so screens can resolve without an event loop."""
def __init__(self) -> None:
self.value: Any = None
self._done = False
def done(self) -> bool:
return self._done
def set_result(self, value: Any) -> None:
self.value = value
self._done = True
# -- contact share URL ----------------------------------------------------------
def test_contact_share_url_matches_meshcore_format() -> None:
"""The contact card URL carries the encoded name, hex key, and node type."""
url = contact_share_url("Base Camp", "AB" * 32, node_type=2)
assert url.startswith("meshcore://contact/add?")
assert "name=Base%20Camp" in url
assert f"public_key={'ab' * 32}" in url # key is normalized to lowercase
assert url.endswith("&type=2")
def test_contact_share_url_defaults_to_companion_type() -> None:
"""With no explicit type the card marks the node as a companion (type 1)."""
assert contact_share_url("n", "00" * 32).endswith("&type=1")
async def test_show_contact_card_pops_the_qr_over_the_link() -> None:
"""The share popup is one floating view: a scannable QR code over the raw
``meshcore://`` link, titled with the node's name."""
from types import SimpleNamespace
from meshterm.ui.config_editor import show_contact_card
calls: list = []
class _Ui:
async def view(self, renderable: Any, **kwargs: Any) -> None:
calls.append((renderable, kwargs))
await show_contact_card(SimpleNamespace(ui=_Ui()), "Hub", "AB" * 32, node_type=2)
((renderable, kwargs),) = calls
assert kwargs["title"] == "Share Hub"
console = Console(width=200, record=True) # wide enough that the link never wraps
console.print(renderable)
out = console.export_text()
assert f"meshcore://contact/add?name=Hub&public_key={'ab' * 32}&type=2" in out
# The QR actually drew its modules — braille dots, one per module.
assert any(0x2801 <= ord(ch) <= 0x28FF for ch in out)
# -- coordinate parsing ----------------------------------------------------------
def test_parse_coords_accepts_comma_and_space_pairs() -> None:
"""A typed pair parses with a comma, a space, or both."""
assert _parse_coords("45.5, -73.6") == (45.5, -73.6)
assert _parse_coords("45.5 -73.6") == (45.5, -73.6)
assert _parse_coords(" 45.5 , -73.6 ") == (45.5, -73.6)
def test_parse_coords_rejects_garbage_and_out_of_range() -> None:
"""Wrong shape or out-of-range degrees surface as validation messages."""
assert _valid_coords("45.5") != True # noqa: E712 - message, not False
assert _valid_coords("45.5, -73.6, 7") != True # noqa: E712
assert _valid_coords("91.0, 0") != True # noqa: E712 - latitude out of range
assert _valid_coords("0, 181") != True # noqa: E712 - longitude out of range
assert _valid_coords("here, there") != True # noqa: E712
assert _valid_coords("45.5, -73.6") is True
# -- the typed-confirmation dialog ------------------------------------------------
def test_typed_confirm_requires_the_exact_word() -> None:
"""Enter only commits once the confirmation word has been typed."""
dialog = TypedConfirmDialog("This erases everything.", "RESET")
dialog.future = _Fut()
dialog.handle("enter") # empty field: nudge, no resolve
assert not dialog.future.done()
assert "doesn't match" in _plain(dialog.render_body(60))
for ch in "nope":
dialog.handle("text", ch)
dialog.handle("enter")
assert not dialog.future.done()
for _ in "nope":
dialog.handle("backspace")
for ch in "RESET":
dialog.handle("text", ch)
dialog.handle("enter")
assert dialog.future.done() and dialog.future.value is True
def test_typed_confirm_is_case_insensitive_and_cancellable() -> None:
"""A lowercase match still confirms (the friction is the word); Esc cancels."""
dialog = TypedConfirmDialog("Danger.", "IMPORT")
dialog.future = _Fut()
for ch in "import":
dialog.handle("text", ch)
dialog.handle("enter")
assert dialog.future.value is True
cancelled = TypedConfirmDialog("Danger.", "IMPORT")
cancelled.future = _Fut()
cancelled.handle("escape")
assert cancelled.future.value is CANCEL
def test_typed_confirm_renders_warning_and_instruction() -> None:
"""The dialog shows the consequence and names the word to type."""
dialog = TypedConfirmDialog("This erases ALL data.", "RESET", title="⚠ Factory reset")
body = _plain(dialog.render_body(60))
assert "This erases ALL data." in body
assert "Type RESET to confirm" in body
assert dialog.border_style == "err"
# -- the map location picker -------------------------------------------------------
class _StubSession:
"""Minimal stand-in for :class:`TuiSession` for driving the picker screen."""
def __init__(self, cols: int = 80, rows: int = 24) -> None:
self._cols, self._rows = cols, rows
def base_body_size(self) -> tuple[int, int]:
return self._cols, self._rows
def invalidate(self) -> None:
pass
class _StubSource:
"""An always-offline tile source, so the picker renders nodes only (no network)."""
available = False
max_zoom = 14
def load_tile(self, z: int, x: int, y: int):
return None
def _picker(markers=None, initial=None):
from meshterm.ui.map_screen import LocationPickScreen
screen = LocationPickScreen(
_StubSession(), markers or [], _StubSource(), 14, initial=initial
)
screen.future = _Fut()
return screen
def test_location_picker_opens_on_the_initial_spot_and_commits_the_centre() -> None:
"""The picker centres on the node's current location and Enter resolves it."""
screen = _picker(initial=(45.5, -73.6))
screen.render_body(80)
assert screen._viewport.center_lat == pytest.approx(45.5)
assert screen._viewport.center_lon == pytest.approx(-73.6)
assert screen._viewport.zoom == 13
screen.handle("enter")
lat, lon = screen.future.value
assert lat == pytest.approx(45.5) and lon == pytest.approx(-73.6)
def test_location_picker_moves_the_crosshair_and_resets_to_initial() -> None:
"""Panning moves the committed point; ``Home`` returns to the starting location."""
screen = _picker(initial=(45.5, -73.6))
screen.render_body(80)
screen.handle("right") # pan east
screen.handle("down") # pan south
screen.handle("enter")
lat, lon = screen.future.value
assert lon > -73.6 and lat < 45.5
screen.handle("home") # back to the initial spot
assert screen._viewport.center_lat == pytest.approx(45.5)
assert screen._viewport.center_lon == pytest.approx(-73.6)
# Typing does nothing here — the picker has no find filter to feed.
view = screen._viewport
screen.handle("text", "d")
assert screen._viewport is view and screen._filter == ""
def test_location_picker_draws_a_live_crosshair_without_keeping_it() -> None:
"""The centre crosshair (with live coordinates) renders each frame but is transient."""
from meshterm.ui.map_render import MapMarker
markers = [MapMarker("Repeater", 45.51, -73.61, is_repeater=True)]
screen = _picker(markers=markers, initial=(45.5, -73.6))
body = _plain(screen.render_body(80))
assert "⌖" in body and "45.5" in body # crosshair + its live coordinates
assert screen._markers == markers # not accumulated across frames
assert "Set location" in screen._title(screen._viewport)
def test_location_picker_without_nodes_or_initial_shows_the_world() -> None:
"""With nothing to frame the picker opens on a world view rather than crashing."""
screen = _picker()
screen.render_body(80)
assert screen._viewport.zoom == 2
screen.handle("home") # reset with nothing to fit stays on the world view
assert screen._viewport.zoom == 2
screen.handle("escape")
assert screen.future.value is None
# -- the editor's flows against the simulator ---------------------------------------
class _FakeSession:
"""A minimal stand-in for the TUI session behind the editor's persistent menu.
The editor keeps its main menu *pushed on the session stack* (so sub-prompts float
over it as modal popups). Here, pushing the menu resolves its future straight from the
script's next ``select`` answer — the same FIFO the sub-prompts draw from — so the
modal-backdrop control flow is exercised without a real full-screen session.
"""
def __init__(self, ui: "_ScriptedUi") -> None:
self.ui = ui
self.pushed: list = []
def push(self, screen: Any) -> None:
from meshterm.ui.tui.screen import CANCEL
self.pushed.append(screen)
value = self.ui._answer("select")
screen.resolve(CANCEL if value is None else value)
def pop(self, screen: Any = None) -> None:
pass
class _ScriptedUi:
"""A fake UI surface answering every prompt from a FIFO script.
Each entry is ``(method, answer)``; the method name is asserted so a flow that asks
an unexpected question fails loudly instead of silently mis-answering. It exposes a
:class:`_FakeSession` so the editor's push-a-persistent-menu path works.
"""
def __init__(self, script: list[tuple[str, Any]]) -> None:
self.script = list(script)
self.notes: list[str] = []
self.views: list[str] = []
self.presented: list[str] = []
self.session = _FakeSession(self)
def _answer(self, method: str) -> Any:
assert self.script, f"unexpected prompt: {method}"
expected, value = self.script.pop(0)
assert expected == method, f"expected {expected} prompt, got {method}"
return value
async def select(self, title: str, items: list, **kwargs: Any) -> Any:
return self._answer("select")
async def dialog(self, prompt: str, buttons: list, **kwargs: Any) -> Any:
return self._answer("dialog")
async def typed_confirm(self, warning: str, word: str, **kwargs: Any) -> bool:
return self._answer("typed_confirm")
async def text(self, title: str, **kwargs: Any) -> Optional[str]:
return self._answer("text")
async def path(self, title: str, **kwargs: Any) -> Optional[str]:
return self._answer("path")
async def autocomplete(self, title: str, choices: list, **kwargs: Any) -> Optional[str]:
return self._answer("autocomplete")
async def confirm(self, title: str, **kwargs: Any) -> Optional[bool]:
return self._answer("confirm")
async def view(self, renderable: Any, **kwargs: Any) -> None:
self.views.append(str(kwargs.get("title", "")))
def note(self, markup: str) -> None:
self.notes.append(markup)
def show(self, *renderables: Any) -> None:
pass
async def present(self, *, title: str = "") -> None:
self.presented.append(title)
def discard(self) -> None:
pass
@pytest.fixture()
def ctx(tmp_path: Path) -> AppContext:
"""A mock-backed application context whose UI is swapped per test."""
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "cfg.db")
context = AppContext(
console=Console(file=__import__("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,
)
yield context
context.repo.close()
def _install(ctx: AppContext, script: list[tuple[str, Any]]) -> _ScriptedUi:
"""Install a scripted UI on the context and return it."""
ui = _ScriptedUi(script)
ctx.ui = ui # type: ignore[assignment]
return ui
async def test_editor_stages_a_setting_and_returns_ops_on_apply(ctx: AppContext) -> None:
"""Editing a value stages it; Apply returns the set operations for the tool to run."""
_install(ctx, [
("select", "name"),
("text", "NewName"),
("select", "__apply__"),
])
ops = await edit_config(ctx)
assert ops == [("set", "name", "NewName")]
async def test_editor_restaging_the_current_value_clears_the_stage(ctx: AppContext) -> None:
"""Typing the device's existing value back un-stages the row (nothing to apply)."""
device = await ctx.device()
current = (await device.get_self_info())["name"]
_install(ctx, [
("select", "name"),
("text", "Changed"),
("select", "name"),
("text", str(current)), # back to what the device already has
("select", "__cancel__"), # nothing staged now — closes without a discard dialog
])
assert await edit_config(ctx) is None
async def test_editor_stages_background_advert_cadence(ctx: AppContext) -> None:
"""Picking a cadence stages it under its sentinel; Apply maps it to its own op."""
_install(ctx, [
("select", "__advert_flood__"),
("select", 48), # every 48 h
("select", "__apply__"),
])
ops = await edit_config(ctx)
assert ops == [("advert_cadence", True, 48)]
async def test_editor_repicking_the_cadence_in_force_clears_the_stage(ctx: AppContext) -> None:
"""Choosing the cadence already in force un-stages the row (nothing to apply)."""
_install(ctx, [
("select", "__advert_direct__"),
("select", 4),
("select", "__advert_direct__"),
("select", 1), # back to the default in force
("select", "__cancel__"), # nothing staged now — closes without a discard dialog
])
assert await edit_config(ctx) is None
async def test_editor_asks_before_discarding_staged_changes(ctx: AppContext) -> None:
"""Cancelling with staged changes confirms; Keep editing returns to the menu."""
_install(ctx, [
("select", "name"),
("text", "NewName"),
("select", "__cancel__"),
("dialog", "keep"), # changed my mind — keep editing
("select", "__apply__"),
])
ops = await edit_config(ctx)
assert ops == [("set", "name", "NewName")]
async def test_editor_discards_staged_changes_when_confirmed(ctx: AppContext) -> None:
"""Cancelling with staged changes and confirming the discard applies nothing."""
_install(ctx, [
("select", "name"),
("text", "NewName"),
("select", None), # Esc from the main menu
("dialog", "discard"),
])
assert await edit_config(ctx) is None
async def test_editor_clean_close_needs_no_confirmation(ctx: AppContext) -> None:
"""With nothing staged, Close leaves immediately — no dialog in the script."""
_install(ctx, [("select", "__cancel__")])
assert await edit_config(ctx) is None
async def test_editor_location_typed_coordinates_stage_both_axes(ctx: AppContext) -> None:
"""Typing a coordinate pair stages latitude and longitude together."""
_install(ctx, [
("select", "__location__"),
("dialog", "type"),
("text", "45.5, -73.6"),
("select", "__apply__"),
])
ops = await edit_config(ctx)
assert ("set", "adv_lat", 45.5) in ops
assert ("set", "adv_lon", -73.6) in ops
async def test_editor_location_map_pick_stages_rounded_coordinates(
ctx: AppContext, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Picking on the map stages the chosen point, rounded to advert precision."""
async def _fake_pick(_ctx: AppContext, *, initial=None):
assert initial is None # the mock device has no fix (0, 0)
return (45.51234567, -73.65432109)
monkeypatch.setattr("meshterm.ui.map_screen.pick_location", _fake_pick)
_install(ctx, [
("select", "__location__"),
("dialog", "map"),
("select", "__apply__"),
])
ops = await edit_config(ctx)
assert ("set", "adv_lat", 45.512346) in ops
assert ("set", "adv_lon", -73.654321) in ops
async def test_editor_location_clear_stages_the_no_fix_pair(ctx: AppContext) -> None:
"""Clear stages 0, 0 — MeshCore's "no fix" value — for both axes."""
device = await ctx.device()
await device.set_coords(45.5, -73.6) # give the device a position to clear
_install(ctx, [
("select", "__location__"),
("dialog", "clear"),
("select", "__apply__"),
])
ops = await edit_config(ctx)
assert ("set", "adv_lat", 0.0) in ops
assert ("set", "adv_lon", 0.0) in ops
async def test_send_advert_sends_zero_hop_and_flood_immediately(ctx: AppContext) -> None:
"""The advert popup sends immediately: zero-hop by default, flood when chosen."""
device = await ctx.device()
sent: list[bool] = []
async def _record(flood: bool = False) -> None:
sent.append(flood)
device.send_advert = _record # type: ignore[method-assign]
ui = _install(ctx, [("select", "zero")])
await send_advert(ctx)
ui = _install(ctx, [("select", "flood")])
await send_advert(ctx)
assert sent == [False, True]
assert any("flood" in n for n in ui.notes)
async def test_device_actions_no_longer_lists_the_advert_row(ctx: AppContext) -> None:
"""The advert action lives only in the main menu now — the actions screen dropped it."""
from meshterm.ui.config_editor import _action_items
labels = [str(getattr(item, "title", "")) for item in _action_items()]
assert not any("advert" in label.lower() for label in labels)
async def test_send_advert_standalone_shares_the_contact_card(ctx: AppContext) -> None:
"""The standalone flow's share option opens the same QR/URI contact-card view."""
ui = _install(ctx, [("select", "share")])
await send_advert(ctx)
assert any(v.startswith("Share ") for v in ui.views)
async def test_send_advert_standalone_backs_out_cleanly(ctx: AppContext) -> None:
"""Backing out of the advert select sends nothing and returns to the menu."""
device = await ctx.device()
async def _boom(flood: bool = False) -> None: # pragma: no cover - must not run
raise AssertionError("no advert should be sent on Back")
device.send_advert = _boom # type: ignore[method-assign]
_install(ctx, [("select", None)])
await send_advert(ctx)
async def test_actions_factory_reset_gates_on_typed_confirmation(ctx: AppContext) -> None:
"""Factory reset only runs behind the typed dialog."""
device = await ctx.device()
await device.set_custom_var("mode", "test")
# Declined: the device is untouched.
_install(ctx, [
("select", "__reset__"),
("typed_confirm", False),
("select", "__cancel__"),
])
await device_actions(ctx)
assert await device.get_custom_vars() == {"mode": "test"}
# Confirmed: the reset runs and the device comes back empty.
_install(ctx, [
("select", "__reset__"),
("typed_confirm", True),
("select", "__cancel__"),
])
await device_actions(ctx)
assert await device.get_custom_vars() == {}
async def test_actions_import_key_gates_on_typed_confirmation(ctx: AppContext) -> None:
"""Importing an identity key requires the typed IMPORT confirmation."""
device = await ctx.device()
new_key = "ab" * 32
_install(ctx, [
("select", "__identity_key__"),
("select", "import"),
("text", new_key),
("typed_confirm", True),
("select", "__cancel__"),
])
await device_actions(ctx)
assert await device.export_private_key() == new_key
async def test_actions_reboot_on_simulator_stays_on_the_screen(ctx: AppContext) -> None:
"""The simulator has no link to drop, so a confirmed reboot just notes and returns."""
ui = _install(ctx, [
("select", "__reboot__"),
("dialog", "reboot"),
("select", "__cancel__"),
])
await device_actions(ctx)
assert ctx.reboot_in_progress is False
assert any("rebooting" in n for n in ui.notes)
async def test_actions_sync_clock_corrects_the_device_time(ctx: AppContext) -> None:
"""Sync clock shows the drift, and confirming writes the host time to the device."""
import time
device = await ctx.device()
assert (await device.get_time()) < int(time.time()) # the mock boots with drift
ui = _install(ctx, [
("select", "__sync_clock__"),
("dialog", "sync"),
("select", "__cancel__"),
])
await device_actions(ctx)
assert abs((await device.get_time()) - int(time.time())) <= 2
assert any("clock set to" in n for n in ui.notes)
async def test_actions_sync_clock_cancel_leaves_the_clock_alone(ctx: AppContext) -> None:
"""Backing out of the sync dialog changes nothing."""
device = await ctx.device()
before = await device.get_time()
_install(ctx, [
("select", "__sync_clock__"),
("dialog", None),
("select", "__cancel__"),
])
await device_actions(ctx)
assert abs((await device.get_time()) - before) <= 2 # still ticking on the old drift
async def test_editor_stages_flood_scope(ctx: AppContext) -> None:
"""The flood scope is a first-class staged setting, hashtag-normalized on apply."""
from meshterm.core.device_config import build_snapshot
from meshterm.tools.config import apply_ops
_install(ctx, [
("select", "flood_scope"),
("text", "alpha"),
("select", "__apply__"),
])
ops = await edit_config(ctx)
assert ops == [("set", "flood_scope", "alpha")]
device = await ctx.device()
await apply_ops(ctx, device, await build_snapshot(device), ops)
assert await device.get_default_flood_scope() == "#alpha"
async def test_actions_backup_writes_immediately(ctx: AppContext, tmp_path: Path) -> None:
"""Backup runs at once: the TOML lands on disk before the screen closes."""
target = tmp_path / "out" / "backup.toml"
ui = _install(ctx, [
("select", "__backup__"),
("path", str(target)),
("select", "__cancel__"),
])
await device_actions(ctx)
assert target.exists()
assert any("wrote" in n for n in ui.notes)
async def test_editor_bool_prompt_uses_the_button_dialog(ctx: AppContext) -> None:
"""A boolean setting is toggled through the On/Off dialog and staged."""
_install(ctx, [
("select", "manual_add_contacts"),
("dialog", True),
("select", "__apply__"),
])
ops = await edit_config(ctx)
assert ops == [("set", "manual_add_contacts", True)]
async def test_editor_custom_var_suggests_known_names(ctx: AppContext) -> None:
"""With existing custom vars the name prompt offers them as suggestions."""
device = await ctx.device()
await device.set_custom_var("existing", "1")
_install(ctx, [
("select", "__custom__"),
("autocomplete", "existing"),
("text", "2"),
("select", "__apply__"),
])
ops = await edit_config(ctx)
assert ops == [("set_custom", "existing", "2")]