A never-heard node has no instant to print, so the timestamp language dropped "never" back to the plain absent token and a contacts listing under --absolute could no longer tell "we have not heard from it" from "this row has no heard time". The absent word survives the switch; only the form of a real time changes. Also drops a dead guard in the preferences writer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
582 lines
26 KiB
Python
582 lines
26 KiB
Python
"""End-to-end tests for what the CLI actually prints and returns.
|
|
|
|
:mod:`tests.test_script_output` covers the vocabulary and :mod:`tests.test_report` the
|
|
seam; this covers the commands built out of both — the real Typer app, driven against the
|
|
simulator, asserting the things a caller depends on: no escape sequences, no wrapped
|
|
records, a header its records line up under, a documented exit status — and, under
|
|
``--json``, a document that parses and a status that did not change because of a rendering
|
|
flag.
|
|
|
|
Every command here is read-only or simulator-backed, so nothing transmits.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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\[")
|
|
|
|
#: A Rich console-markup tag. The scripted console no longer *interprets* markup — a node
|
|
#: name is remote data and `[bold]` in one is a name, not a style — which means a tag
|
|
#: MeshTerm writes itself is no longer consumed either, and lands in the caller's pipe.
|
|
_MARKUP = re.compile(r"\[/?(?:ok|err|warn|muted|accent|brand|bold|dim|reverse)\b[^\]]*\]")
|
|
|
|
#: The shape of a drawn route: one or more hops, each a bare name with its hash in
|
|
#: parentheses (or one of the two halves alone, where history knows only one), joined by
|
|
#: the spaced arrow. This is the whole grammar, and the arrow is what makes it one — a
|
|
#: name holding a comma no longer needs quoting to stay one hop.
|
|
_HOP = r"[^\n→]+?"
|
|
_ROUTE_LINE = re.compile(f"{_HOP}(?: → {_HOP})*")
|
|
|
|
#: The shape of a path *spec*: comma-joined lowercase hex, exactly what ``--path`` takes
|
|
#: back. The one line on either face that round-trips, so nothing may creep into it.
|
|
_PATH_SPEC = re.compile(r"[0-9a-f]+(?:,[0-9a-f]+)*")
|
|
|
|
#: 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", "HASH", "LOCATION", "KEY"]),
|
|
("info", [], ["name", "public_key", "role"]),
|
|
("config", ["show"], ["name", "radio_freq", "tx_power"]),
|
|
("preferences", ["show"], ["PREFERENCE", "VALUE", "DEFAULT", "DESCRIPTION"]),
|
|
("chat", ["list"], ["CONVERSATION", "KIND", "UNREAD", "LAST", "LAST_TEXT"]),
|
|
("platform", [], ["platform", "icons"]),
|
|
]
|
|
|
|
|
|
def _leaf_commands() -> list[tuple[str, ...]]:
|
|
"""Every registered subcommand path, walked out of the real Typer app.
|
|
|
|
Derived rather than listed: a hand-kept roster covers whatever was reworked the day it
|
|
was written, and a command added afterwards inherits none of it. Two rule violations
|
|
shipped inside commands the roster did not name — a 16384-cell rule in ``about`` and
|
|
markup tags in ``config export-key``.
|
|
"""
|
|
from typer.main import get_command
|
|
|
|
from meshterm.cli import app
|
|
|
|
def walk(command, prefix: tuple[str, ...] = ()) -> list[tuple[str, ...]]: # noqa: ANN001
|
|
found: list[tuple[str, ...]] = []
|
|
for name, sub in sorted(getattr(command, "commands", {}).items()):
|
|
here = (*prefix, name)
|
|
found.extend(walk(sub, here) if getattr(sub, "commands", None) else [here])
|
|
return found
|
|
|
|
return walk(get_command(app))
|
|
|
|
|
|
#: Commands that own the terminal or run until interrupted: they have no one-shot output.
|
|
_NOT_ONE_SHOT = {("monitor",), ("chat", "listen"), ("tx-optimize",)}
|
|
|
|
#: The one command whose output *is* its colour, and says so (:mod:`meshterm.ui.specimen`).
|
|
_KEEPS_ITS_COLOUR = {("specimen",)}
|
|
|
|
|
|
@pytest.fixture()
|
|
def run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # noqa: ANN201 - a closure
|
|
"""Invoke the real CLI against the simulator, in a config directory of its own.
|
|
|
|
``--db`` moves the history and *nothing else*: the contact cache, the outbox, the
|
|
channel cache, the stored admin passwords and any device profiles all live in the
|
|
config directory regardless. So a suite that set only ``--db`` read whatever was in
|
|
the developer's own ``~/.meshterm`` — which is how a contact named ``[/]Bob``, planted
|
|
by hand on one machine, could crash this suite on that machine and pass everywhere
|
|
else. ``$MESHTERM_HOME`` is the override that moves the whole directory, and it is
|
|
the one a test wants.
|
|
"""
|
|
from meshterm.cli import app
|
|
|
|
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path / "home"))
|
|
runner = CliRunner()
|
|
|
|
def invoke(*args: str): # noqa: ANN202
|
|
return runner.invoke(app, ["--mock", "--db", str(tmp_path / "test.db"), *args])
|
|
|
|
return invoke
|
|
|
|
|
|
#: Commands that refuse a machine-readable face outright, and say so as a usage error.
|
|
_REFUSES_JSON = {("specimen",)}
|
|
|
|
|
|
# -- 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.stdout)
|
|
|
|
|
|
@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.stdout, 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.stdout) & 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.
|
|
|
|
``result.output`` is a third buffer that *both* streams copy into, so a rule about
|
|
stdout asserted against it passes when the field it wants only ever appeared on
|
|
stderr — and fails for the wrong reason the moment anything logs.
|
|
"""
|
|
return result.stdout.splitlines()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"leaf",
|
|
[c for c in _leaf_commands() if c not in _NOT_ONE_SHOT | _KEEPS_ITS_COLOUR],
|
|
ids=lambda leaf: "-".join(leaf),
|
|
)
|
|
def test_every_registered_command_obeys_the_rules(run, leaf) -> None: # noqa: ANN001
|
|
"""The rules belong to the CLI, not to the six commands somebody listed by hand.
|
|
|
|
Run bare, a command that needs arguments fails harmlessly as a usage error and still
|
|
proves it wrote nothing awkward on the way out; a command that can answer without
|
|
arguments is exercised for real. That is where both shipped violations were — the
|
|
``---`` in ``about`` drawn as a rule the width of a 16384-cell console, and the
|
|
``[muted]`` tags around what ``config export-key`` exists to emit.
|
|
"""
|
|
out = run(*leaf).stdout
|
|
assert not _ANSI.search(out), f"{' '.join(leaf)} put an escape sequence on stdout"
|
|
assert not set(out) & set("─│┌┐└┘├┤━┃╭╮╰╯"), f"{' '.join(leaf)} drew a frame or a rule"
|
|
assert not _MARKUP.search(out), f"{' '.join(leaf)} printed a console markup tag"
|
|
for line in out.splitlines():
|
|
assert line == line.rstrip(), f"{' '.join(leaf)} padded a line: {line!r}"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"leaf",
|
|
[c for c in _leaf_commands() if c not in _NOT_ONE_SHOT | _KEEPS_ITS_COLOUR],
|
|
ids=lambda leaf: "-".join(leaf),
|
|
)
|
|
def test_every_registered_command_answers_json_that_parses(run, leaf) -> None: # noqa: ANN001
|
|
"""``--json`` is a rendering, so it belongs to the CLI rather than to a list of tools.
|
|
|
|
It reached two commands out of twenty and stopped, because each one that wanted it had
|
|
to restate its whole answer above the rendering. The same sweep that keeps the plain
|
|
rules honest now runs every command a second time and reads what comes back: **every
|
|
line on stdout is one complete document**, on success and on empty alike, and a
|
|
command that fails writes nothing there at all.
|
|
"""
|
|
result = run("--json", *leaf)
|
|
for line in result.stdout.splitlines():
|
|
json.loads(line) # raises, with the offending line, if anything else got out
|
|
if result.exit_code in (exitcodes.OK, exitcodes.NO_RESULT):
|
|
assert result.stdout.strip(), f"{' '.join(leaf)} exited {result.exit_code} silently"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"leaf",
|
|
[c for c in _leaf_commands() if c not in _NOT_ONE_SHOT | _KEEPS_ITS_COLOUR],
|
|
ids=lambda leaf: "-".join(leaf),
|
|
)
|
|
def test_the_two_faces_never_disagree_about_the_exit_status(run, leaf) -> None: # noqa: ANN001
|
|
"""``--json`` changes the rendering, never the report.
|
|
|
|
The status is the report — it is what a caller branches on — and a flag about *how the
|
|
answer looks* has no business moving it. This is the cross-check that stops the two
|
|
faces drifting the way they did before: ``devices --json`` used to exit ``0`` where the
|
|
plain path said ``5``, so a script could not tell an empty scan from a full one by
|
|
asking the same question twice.
|
|
"""
|
|
assert run("--json", *leaf).exit_code == run(*leaf).exit_code, " ".join(leaf)
|
|
|
|
|
|
@pytest.mark.parametrize("leaf", sorted(_REFUSES_JSON), ids=lambda leaf: "-".join(leaf))
|
|
def test_a_command_whose_output_is_the_colour_refuses_json_loudly(run, leaf) -> None: # noqa: ANN001
|
|
"""``specimen``'s output *is* the colour; a document of it would be an empty gesture.
|
|
|
|
A loud refusal is the honest answer, and it is the same one this project already gives
|
|
for the map, the dashboard and the live feed — which simply have no subcommand to
|
|
refuse from. It is a usage error, so stdout stays empty and the sentence goes where
|
|
every other failure's does.
|
|
"""
|
|
result = run("--json", *leaf)
|
|
assert result.exit_code == exitcodes.USAGE
|
|
assert result.stdout == ""
|
|
assert "machine-readable" in result.stderr
|
|
|
|
|
|
def test_the_menu_has_no_document_and_says_so(run) -> None: # noqa: ANN001
|
|
"""``meshterm --json`` with no subcommand would launch a full-screen session.
|
|
|
|
There is no document an interactive session can emit, and starting one would hang
|
|
whatever was waiting to parse it.
|
|
"""
|
|
result = run("--json")
|
|
assert result.exit_code == exitcodes.USAGE
|
|
assert result.stdout == ""
|
|
|
|
|
|
def test_the_sweep_actually_covers_the_whole_command_surface() -> None:
|
|
"""A derived list is only a guarantee while it is still finding the commands.
|
|
|
|
If the walk breaks — a Typer upgrade moving ``commands``, say — every parametrised
|
|
case silently becomes zero cases and the sweep passes by covering nothing.
|
|
"""
|
|
leaves = _leaf_commands()
|
|
assert len(leaves) > 40, f"only found {len(leaves)} commands: {leaves}"
|
|
for expected in [("about",), ("config", "export-key"), ("contacts",), ("trace",)]:
|
|
assert expected in leaves, expected
|
|
|
|
|
|
# -- listings -------------------------------------------------------------------------
|
|
|
|
|
|
def test_contacts_names_every_node_bare_and_ages_every_time(run) -> None: # noqa: ANN001
|
|
"""The two rules that reversed, checked on the listing they were reversed for.
|
|
|
|
A name is bare — the alignment is what makes it one field now — and ``HEARD`` is an
|
|
age, because "heard recently?" is the question this listing is opened to ask and an ISO
|
|
instant makes the reader do arithmetic to answer it. ``--absolute`` puts the instants
|
|
back, for anyone who wants them.
|
|
"""
|
|
header, *records = result_lines(run("contacts"))
|
|
assert header.split() == ["NAME", "TYPE", "HEARD", "PKTS", "HASH", "LOCATION", "KEY"]
|
|
assert records, "the simulator always has contacts"
|
|
for line in records:
|
|
assert not line.startswith('"')
|
|
age = line.split()[2]
|
|
assert age in ("now", "never") or age[-1] in "mhdw"
|
|
|
|
# `--absolute` asks for a different *form* of time, not for one fewer fact, so
|
|
# `never` survives it: a node that has never been heard has no instant to print.
|
|
absolute = result_lines(run("--absolute", "contacts"))[1:]
|
|
for line in absolute:
|
|
stamp = line.split()[2]
|
|
assert stamp in ("-", "never") 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_every_setting_show_prints_is_one_set_takes_back(run) -> None: # noqa: ANN001
|
|
"""The round-trip claim, checked on every setting rather than on the two easy ones.
|
|
|
|
``config show > f`` and feeding ``f`` back in is the obvious thing to do with a dump,
|
|
and the failure mode is silent: an empty string printed as ``""`` used to parse back
|
|
as *the two quote characters*, and the next dump looked identical, so nothing ever
|
|
said the setting had been replaced. A refusal is fine here — you cannot set a value
|
|
the firmware never reported — but a value that parses to something else is not.
|
|
"""
|
|
from meshterm.core import device_config as dc
|
|
from meshterm.tools.config import _script_value
|
|
|
|
probes = {
|
|
"str": ["", "Yagi-Repeater", "a name with spaces"],
|
|
"bool": [True, False],
|
|
"int": [0, 1, 12],
|
|
"float": [0.0, 3.5],
|
|
}
|
|
checked = 0
|
|
for _category, specs in dc.settings_by_category():
|
|
for spec in specs:
|
|
if spec.value_type == "enum" and spec.choices:
|
|
values: list = list(spec.choices)
|
|
else:
|
|
values = probes.get(spec.value_type, [])
|
|
for value in values:
|
|
printed = _script_value(spec, value)
|
|
try:
|
|
parsed = dc.parse_value(spec, printed, {})
|
|
except dc.DeviceConfigError:
|
|
continue # a loud refusal is honest; a wrong value is not
|
|
checked += 1
|
|
assert parsed == value, f"{spec.key}: show printed {printed!r} -> {parsed!r}"
|
|
assert checked > 40, f"only {checked} round-trips exercised"
|
|
|
|
|
|
def test_an_unreported_pin_is_not_masked_as_though_one_were_set(run) -> None: # noqa: ANN001
|
|
"""Bullets say "there is a secret here". An absence must not borrow that claim.
|
|
|
|
``conceal`` already declines to mask the *menu's* absence token for exactly this
|
|
reason, but the scripted token is ``-``, which it had never been shown — so a radio
|
|
that had never reported a PIN dumped six bullets, and the reader (or the bug report
|
|
they pasted it into) read that as PIN-locked.
|
|
"""
|
|
from meshterm.ui import script
|
|
from meshterm.ui.config_editor import MASK_MARK, conceal
|
|
|
|
# The rule at the boundary where it broke: a real value is masked, an absence is not,
|
|
# and each surface says which glyph it writes an absence with.
|
|
assert conceal("1234", absent=script.NONE) == MASK_MARK * 6
|
|
assert conceal(script.NONE, absent=script.NONE) == script.NONE
|
|
assert conceal("?") == "?"
|
|
|
|
# And the two surfaces must never disagree about the same radio: `show` wears bullets
|
|
# only where `get` has something to conceal.
|
|
values = dict(
|
|
line.split(None, 1) for line in result_lines(run("config", "show")) if " " in line
|
|
)
|
|
got = run("config", "get", "device_pin").output.strip()
|
|
assert (MASK_MARK in values["device_pin"]) == (got != script.NONE), (
|
|
f"show says {values['device_pin']!r} while get says {got!r}"
|
|
)
|
|
|
|
|
|
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_draws_its_route_with_arrows_and_keeps_its_path_in_commas(run) -> None: # noqa: ANN001
|
|
"""The sharpest of the reversals, and the reason is the project's own lexicon.
|
|
|
|
A *path* is a spec you compose and can paste back into ``--path``, so it stays
|
|
comma-separated and round-trippable. A *route* is what a walk actually did; it is not a
|
|
spec, and rendering it with commas promised a round trip it does not have. Our own node
|
|
is still named like any other hop — the menu's ``★`` says "you already know who this
|
|
is", which is true of the reader and false of whoever opens the file later.
|
|
"""
|
|
result = run("trace-path", "--path", "a1,d4,a1")
|
|
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
|
|
assert "→" in route
|
|
assert _ROUTE_LINE.fullmatch(route), route
|
|
|
|
assert _PATH_SPEC.fullmatch(facts["path"]), facts["path"]
|
|
assert "→" not in facts["path"]
|
|
|
|
|
|
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-path", "--path", "a1,d4,a1"))
|
|
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_and_never_on_stdout(run) -> None: # noqa: ANN001
|
|
"""``program: what went wrong`` — so a caller parsing stdout never has to filter it."""
|
|
result = run("config", "get", "nosuchkey")
|
|
assert result.stdout == ""
|
|
assert "meshterm: unknown setting" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
("config", "reboot"),
|
|
("config", "factory-reset"),
|
|
("config", "import-key", "00" * 32),
|
|
("channels", "clear", "1"),
|
|
("preferences", "reset"),
|
|
],
|
|
ids=["reboot", "factory-reset", "import-key", "channels-clear", "preferences-reset"],
|
|
)
|
|
def test_a_destructive_command_without_yes_is_a_silent_usage_error(run, args) -> None: # noqa: ANN001
|
|
"""A missing confirmation *is* a bad argument, and it says so where errors go.
|
|
|
|
These used to print a red refusal on stdout and exit 1 — a colour and a sentence that
|
|
is not the command's answer, in whatever was reading the command's answer.
|
|
"""
|
|
result = run(*args)
|
|
assert result.exit_code == exitcodes.USAGE
|
|
assert result.stdout == ""
|
|
assert "--yes" in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args,flag",
|
|
[
|
|
(("contacts", "--sort", "bogus"), "--sort"),
|
|
(("records", "--category", "long_hual"), "--category"),
|
|
],
|
|
ids=["contacts-sort", "records-category"],
|
|
)
|
|
def test_a_value_outside_a_closed_set_is_refused_rather_than_ignored(run, args, flag) -> None: # noqa: ANN001
|
|
"""A typo in a closed-set option used to be answered with a different question.
|
|
|
|
``--sort bogus`` quietly sorted by the default, and ``--category long_hual`` quietly
|
|
selected *every* discipline — which then came back as exit ``5``, the code that means
|
|
a real empty result. A caller branching on ``5`` could not tell "there are no records"
|
|
from "you misspelled the discipline", which is the worst answer a closed set can give.
|
|
"""
|
|
result = run(*args)
|
|
assert result.exit_code == exitcodes.USAGE
|
|
assert result.stdout == ""
|
|
assert flag in result.stderr
|
|
|
|
|
|
def test_a_profile_that_names_nothing_is_refused_before_anything_transmits(run) -> None: # noqa: ANN001
|
|
"""``--profile`` is a claim about *which* radio, and an unkeepable one is not a default.
|
|
|
|
An unknown name used to fall through to ordinary discovery, so a scheduled
|
|
``--profile yagi config advert`` with a typo transmitted from whichever companion
|
|
happened to be attached — the one failure mode where being quietly wrong puts a packet
|
|
on the air.
|
|
"""
|
|
result = run("--profile", "nosuchprofile", "info")
|
|
assert result.exit_code == exitcodes.USAGE
|
|
assert result.stdout == ""
|
|
assert "nosuchprofile" in result.stderr
|
|
|
|
|
|
def test_a_port_that_will_not_open_is_no_device_not_a_device_failure(run) -> None: # noqa: ANN001
|
|
"""The two device statuses answer different questions, and this one answered wrong.
|
|
|
|
``3`` says nothing was transmitted — look at what is plugged in. ``4`` says the radio
|
|
was reached and the operation failed, so a retry is reasonable. An unopenable
|
|
``--port`` reported ``4``, with the message "the connection to the device was lost"
|
|
describing a connection there had never been. The manual's own ``case $?`` recipe
|
|
branches on exactly this split.
|
|
"""
|
|
from typer.testing import CliRunner
|
|
|
|
from meshterm.cli import app
|
|
|
|
result = CliRunner().invoke(app, ["--port", "NOSUCHPORT99", "info"])
|
|
assert result.exit_code == exitcodes.NO_DEVICE
|
|
assert "could not open" in result.stderr
|
|
assert "lost" not in result.stderr
|
|
|
|
|
|
def test_an_empty_result_writes_nothing_to_stdout_at_all(run) -> None: # noqa: ANN001
|
|
"""Not a "no channels configured" line: the status carries it, so the pipe stays clean."""
|
|
result = run("channels", "list")
|
|
assert result.exit_code == exitcodes.NO_RESULT
|
|
assert result.stdout == ""
|
|
|
|
|
|
def test_mock_and_an_explicit_radio_are_a_bad_command_line(run) -> None: # noqa: ANN001
|
|
"""One flag says "pretend", the other names one exact radio, and both cannot be true.
|
|
|
|
``--mock`` used to win silently, so a scheduled ``--port COM7 --mock info`` reported on
|
|
a simulator while reading as though it had reached the radio. Two contradictory claims
|
|
about which device to talk to is a bad command line, not a precedence question.
|
|
"""
|
|
from typer.testing import CliRunner
|
|
|
|
from meshterm.cli import app
|
|
|
|
for flag, value in (("--port", "COM7"), ("--ble", "AA:BB"), ("--tcp", "host:1")):
|
|
result = CliRunner().invoke(app, ["--mock", flag, value, "info"])
|
|
assert result.exit_code == exitcodes.USAGE, flag
|
|
assert result.stdout == ""
|
|
assert flag in result.stderr
|
|
|
|
|
|
def test_an_acknowledgement_reaches_the_person_without_reaching_the_pipe(run) -> None: # noqa: ANN001
|
|
"""``ack`` used to be dropped on the CLI, and the reason only covered half the case.
|
|
|
|
An acknowledgement is not the answer, and a caller redirecting stdout must catch only
|
|
the answer — but stdout is not the only stream. stderr honours that rule exactly while
|
|
giving a person watching a five-minute ``config restore`` the setting-by-setting ✓ they
|
|
had no way to see.
|
|
"""
|
|
result = run("config", "set", "tx_power", "20")
|
|
assert result.exit_code == exitcodes.OK
|
|
assert result.stdout == ""
|
|
assert "tx_power" in result.stderr
|
|
|
|
|
|
def test_a_closing_message_goes_to_stderr_too(run) -> None: # noqa: ANN001
|
|
"""A closing count is for a person, not a record for a parser.
|
|
|
|
It restates what the listing above already showed and what ``$?`` already says, so it
|
|
has no business in a redirect — and it is exactly what someone wants after a command
|
|
that scrolled past them.
|
|
"""
|
|
result = run("records")
|
|
assert result.exit_code == exitcodes.NO_RESULT
|
|
assert result.stdout == ""
|
|
assert "records stored" in result.stderr
|
|
|
|
|
|
# -- 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
|