The CLI had grown the menu's manners -- colour, boxes, section headings, glyph columns, a closing check mark -- and every one of them is in the way of the thing it is actually for. It now prints like a standard Unix utility: plain text, no escape sequences at all, no frames, one record per line, and nothing wrapped, so a long line runs off the right instead of folding its fields onto a second one. meshterm/ui/script.py is the whole vocabulary. Listings are an uppercase header and space-aligned records; key/value blocks are sysctl's shape, and a `get` prints the bare value alone. Names are quoted wherever they share a line, because a name can hold a space or a comma and a bare one is not a field. A route reads "Origin" (3d),"Relay" (f2),"Us" (a1) -- the hash outside the quotes, our own node a hop like any other rather than a star, since the hash is what carries the identity that colour carries on screen. Times are absolute local ISO-8601 and "-" is the one token for absent. The return value is now half the report: 0 ok, 1 failure, 2 usage, 3 no device, 4 the device was reached and the operation failed, 5 nothing to report. That last one is the point -- a command that ran fine and found nothing prints nothing and says so in $?, so a script never has to count output lines to tell an empty mesh from a full one. Errors and progress moved to stderr, and an expected failure no longer arrives as a traceback. Two things lose their scripted face rather than get a degraded one. The map is a picture whose nodes are told apart by colour: monochrome it would be unreadable, coloured it could not be piped anywhere useful, and the nodes themselves are still in `contacts`. The QR codes are a second rendering of a link the output already prints. ui.ack joins ui.note to draw the line the change needed: a note is output, an acknowledgement is reassurance a person wants and a script must filter out, so the menu prints it and the CLI drops it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
206 lines
8.8 KiB
Python
206 lines
8.8 KiB
Python
"""End-to-end tests for what the CLI actually prints and returns.
|
|
|
|
:mod:`tests.test_script_output` covers the vocabulary; this covers the commands built out
|
|
of it — the real Typer app, driven against the simulator, asserting the four things a
|
|
script depends on: no escape sequences, no wrapped records, a header its records line up
|
|
under, and a documented exit status.
|
|
|
|
Every command here is read-only or simulator-backed, so nothing transmits.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from meshterm.core import exitcodes
|
|
|
|
#: Any ANSI escape sequence. Nothing on the CLI's stdout may match this.
|
|
_ANSI = re.compile(r"\x1b\[")
|
|
|
|
#: The shape of a scripted path line: one or more quoted node names, each optionally
|
|
#: followed by its hash in parentheses *outside* the quotes, joined by a bare comma. This
|
|
#: is the whole grammar — a line matching it splits, whatever the names hold.
|
|
_HOP = r'"(?:[^"\\]|\\.)*"(?: \([^)]*\))?'
|
|
_PATH_LINE = re.compile(f"{_HOP}(?:,{_HOP})*")
|
|
|
|
#: The commands whose output is a listing or a key/value block, and the fields a caller
|
|
#: should find in each. Every one runs against the mock companion or the database alone.
|
|
_LISTINGS: list[tuple[str, list[str], list[str]]] = [
|
|
# (command, arguments, headings or keys the output must carry)
|
|
("contacts", [], ["NAME", "TYPE", "HEARD", "PKTS", "KEY"]),
|
|
("info", [], ["name", "public_key", "role"]),
|
|
("config", ["show"], ["name", "radio_freq", "tx_power"]),
|
|
("preferences", ["show"], ["PREFERENCE", "VALUE", "DEFAULT"]),
|
|
("chat", ["list"], ["CONVERSATION", "KIND", "UNREAD", "LAST_TIME", "LAST_TEXT"]),
|
|
("platform", [], ["platform", "icons"]),
|
|
]
|
|
|
|
|
|
@pytest.fixture()
|
|
def run(tmp_path: Path): # noqa: ANN201 - a closure over the runner
|
|
"""Invoke the real CLI against the simulator and a scratch database."""
|
|
from meshterm.cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
def invoke(*args: str): # noqa: ANN202
|
|
return runner.invoke(app, ["--mock", "--db", str(tmp_path / "test.db"), *args])
|
|
|
|
return invoke
|
|
|
|
|
|
# -- the four rules -------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS])
|
|
def test_a_command_prints_no_escape_sequence(run, command, args, fields) -> None: # noqa: ANN001
|
|
"""Nothing is coloured: stdout is a pipe more often than a terminal."""
|
|
result = run(command, *args)
|
|
assert result.exit_code == exitcodes.OK, result.output
|
|
assert not _ANSI.search(result.output)
|
|
|
|
|
|
@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS])
|
|
def test_a_command_carries_the_fields_a_caller_looks_for(run, command, args, fields) -> None: # noqa: ANN001
|
|
"""The headings and keys are the contract; renaming one silently breaks a script."""
|
|
result = run(command, *args)
|
|
for field in fields:
|
|
assert field in result.output, f"{command} lost {field}"
|
|
|
|
|
|
@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS])
|
|
def test_a_command_draws_no_frame_and_no_rule(run, command, args, fields) -> None: # noqa: ANN001
|
|
"""No borders, no boxes, no header rules — the command typed is the title."""
|
|
result = run(command, *args)
|
|
assert not set(result.output) & set("─│┌┐└┘├┤━┃")
|
|
|
|
|
|
@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS])
|
|
def test_a_command_leaves_no_trailing_whitespace(run, command, args, fields) -> None: # noqa: ANN001
|
|
"""Column padding must not become invisible spaces at the end of every record."""
|
|
for line in result_lines(run(command, *args)):
|
|
assert line == line.rstrip(), repr(line)
|
|
|
|
|
|
def result_lines(result) -> list[str]: # noqa: ANN001
|
|
"""The command's stdout as lines, with the trailing blank dropped."""
|
|
return result.output.splitlines()
|
|
|
|
|
|
# -- listings -------------------------------------------------------------------------
|
|
|
|
|
|
def test_contacts_quotes_every_name_and_stamps_every_time(run) -> None: # noqa: ANN001
|
|
"""A record is quoted where a name could hold a space, and timed absolutely."""
|
|
header, *records = result_lines(run("contacts"))
|
|
assert header.split() == ["NAME", "TYPE", "HEARD", "PKTS", "KEY"]
|
|
assert records, "the simulator always has contacts"
|
|
for line in records:
|
|
assert line.startswith('"')
|
|
stamp = line.split()[-3]
|
|
assert stamp == "-" or stamp[:4].isdigit()
|
|
|
|
|
|
def test_contacts_never_elides_a_key(run) -> None: # noqa: ANN001
|
|
"""A truncated key is not something a caller can hand back to ``--to`` or ``--path``."""
|
|
for line in result_lines(run("contacts"))[1:]:
|
|
assert "…" not in line and not line.endswith("...")
|
|
|
|
|
|
def test_a_config_line_can_be_typed_back_into_config_set(run) -> None: # noqa: ANN001
|
|
"""``show`` names each setting by the key ``get``/``set`` take, and prints its value."""
|
|
values = dict(line.split(None, 1) for line in result_lines(run("config", "show")) if line)
|
|
assert values["name"] == "MockCompanion"
|
|
# An enum prints its number, not the reader's label: `parse_value` takes the number.
|
|
assert values["adv_loc_policy"].isdigit()
|
|
assert run("config", "get", "name").output.strip() == "MockCompanion"
|
|
|
|
|
|
def test_config_get_prints_the_bare_value(run) -> None: # noqa: ANN001
|
|
"""The caller named the key; repeating it back is one more thing to strip off."""
|
|
assert run("config", "get", "tx_power").output.strip() == "20"
|
|
|
|
|
|
def test_preferences_get_prints_a_value_its_own_set_would_take(run) -> None: # noqa: ANN001
|
|
"""The page's ``5 s`` carries a unit that ``preferences set`` will not accept back."""
|
|
assert run("preferences", "get", "trace_cooldown_s").output.strip() == "5"
|
|
|
|
|
|
# -- path lines -----------------------------------------------------------------------
|
|
|
|
|
|
def test_a_trace_prints_its_route_as_the_scripted_path_line(run) -> None: # noqa: ANN001
|
|
"""Quoted names, hashes outside the quotes, comma-separated, no star for our own node."""
|
|
result = run("trace", "--target", "Alice")
|
|
assert result.exit_code == exitcodes.OK, result.output
|
|
facts = dict(line.split(None, 1) for line in result_lines(result) if line and " " in line)
|
|
assert facts["success"] == "yes"
|
|
route = facts["route"]
|
|
assert "★" not in route and "→" not in route
|
|
assert _PATH_LINE.fullmatch(route), route
|
|
|
|
|
|
def test_a_trace_reports_its_per_hop_readings_under_a_header(run) -> None: # noqa: ANN001
|
|
"""The second block is a record per hop, joined to the route line by hash."""
|
|
lines = result_lines(run("trace", "--target", "Alice"))
|
|
header = next(line for line in lines if line.startswith("HOP"))
|
|
assert header.split() == ["HOP", "FROM", "TO", "SNR_DB"]
|
|
|
|
|
|
# -- exit statuses --------------------------------------------------------------------
|
|
|
|
|
|
def test_an_empty_result_is_its_own_status_and_prints_nothing(run) -> None: # noqa: ANN001
|
|
"""The simulator configures no channel slots: the command worked and found nothing.
|
|
|
|
``grep`` conflates "found nothing" with "worked"; this is the whole reason
|
|
:data:`~meshterm.core.exitcodes.NO_RESULT` exists.
|
|
"""
|
|
result = run("channels", "list")
|
|
assert result.exit_code == exitcodes.NO_RESULT
|
|
assert result.output.strip() == ""
|
|
|
|
|
|
def test_a_bad_flag_is_a_usage_error(run) -> None: # noqa: ANN001
|
|
"""Click's own status, named in the table so it is complete."""
|
|
assert run("contacts", "--no-such-flag").exit_code == exitcodes.USAGE
|
|
|
|
|
|
def test_a_bad_setting_key_is_a_plain_failure(run) -> None: # noqa: ANN001
|
|
"""Retrying will not help: the caller has to change what it asked for."""
|
|
result = run("config", "get", "nosuchkey")
|
|
assert result.exit_code == exitcodes.FAILURE
|
|
|
|
|
|
def test_a_failure_says_so_on_stderr_in_the_utility_shape(run) -> None: # noqa: ANN001
|
|
"""``program: what went wrong`` — so a caller redirecting stdout still sees it."""
|
|
runner = CliRunner()
|
|
from meshterm.cli import app
|
|
|
|
result = runner.invoke(app, ["--mock", "config", "get", "nosuchkey"])
|
|
assert "meshterm: unknown setting" in result.output
|
|
|
|
|
|
# -- the map --------------------------------------------------------------------------
|
|
|
|
|
|
def test_the_map_has_no_cli_command_at_all(run) -> None: # noqa: ANN001
|
|
"""A picture has no scripted face; the located nodes stay in ``contacts``."""
|
|
assert run("map").exit_code == exitcodes.USAGE
|
|
|
|
|
|
# -- help -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_help_is_plain_and_names_every_exit_status(run) -> None: # noqa: ANN001
|
|
"""Click's own help: no boxed panels, no colour, and the status table under it."""
|
|
result = run("--help")
|
|
assert not _ANSI.search(result.output)
|
|
assert not set(result.output) & set("─│╭╮╰╯")
|
|
for code in exitcodes.MEANINGS:
|
|
assert f"{code} " in result.output
|