Files
MeshTerm/tests/test_cli_contract.py
T
Jean-Pierre MartineauandClaude Opus 5.5 efc02ddced The code text uses the glossary words in more places
A final pass over the docstrings and comments: "the own X of Y" is now
"Y's own X" (84 places), a navigation entry is a screen on the stack, our
node is our node, a paint is a paint, the quoted UX labels are in code
font, and a list of punctuation glyphs is in code font.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 21:45:04 -04:00

929 lines
41 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""End-to-end tests for what the CLI prints and returns.
:mod:`tests.test_script_output` covers the vocabulary. :mod:`tests.test_report` covers the
seam. This file covers the commands that use both. The tests run the real Typer app against
the simulator. They check what a caller depends on:
- no escape sequences,
- no wrapped records,
- a header that the records line up under,
- a documented exit status.
Under ``--json``, they also check that the document parses, and that a rendering flag does
not change the exit status.
Each command here is read-only or uses the simulator, 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 does not interpret markup. A node name is
#: remote data, and `[bold]` in a name is a name, not a style. Thus a tag that MeshTerm
#: writes is also not consumed, and it goes into the pipe of the caller.
_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 hop is a name with its hash in
#: parentheses (or only one of the two, if the stored history has only one). The arrow with
#: spaces joins the hops. This is the whole grammar. Because of the arrow, a name that has a
#: comma does not need quotes to stay one hop.
_HOP = r"[^\n→]+?"
_ROUTE_LINE = re.compile(f"{_HOP}(?: → {_HOP})*")
#: The shape of a path spec: lowercase hex joined by commas, exactly what ``--path`` takes.
#: It is the only line on each face that round-trips, so no other text can be in 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 that a caller
#: must find in each. Each command runs against only the mock companion or the database.
_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, ...]]:
"""All the registered subcommand paths, found by a walk through the real Typer app.
The code finds the paths and does not use a list, because a list that a person keeps
covers only the commands that were changed on the day that the person wrote it. A command
that is added later is not in the list. Two rule violations shipped in commands that
were not in the list: a rule of 16384 cells 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 the user interrupts them. They have no
#: one-shot output.
_NOT_ONE_SHOT = {("monitor",), ("chat", "listen"), ("tx-optimize",), ("emulate",)}
#: The one command whose output is its colour, and the command says this
#: (:mod:`meshterm.ui.specimen`).
_KEEPS_ITS_COLOUR = {("specimen",)}
#: Extra arguments that the derived sweep passes to a leaf. The dictionary is empty because
#: ``devices`` now knows that ``--mock`` means no scan. Before, ``devices`` listed the
#: serial ports and also switched on the Bluetooth adapter of this machine three times in
#: each run of the suite, and the sweep passed ``--no-ble`` to prevent this. The dictionary
#: stays as a hook, in case another leaf uses hardware.
_SWEEP_ARGS: dict[tuple[str, ...], tuple[str, ...]] = {}
def _sweep(leaf: tuple[str, ...]) -> tuple[str, ...]:
"""The leaf as the sweep runs it: its own path, and a flag that prevents hardware use."""
return (*leaf, *_SWEEP_ARGS.get(leaf, ()))
@pytest.fixture()
def run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # noqa: ANN201 - a closure
"""Run the real CLI against the simulator, in a config directory of its own.
``--db`` moves only the history. The contact cache, the outbox, the channel cache, the
stored admin passwords, and the device profiles stay in the config directory. Thus a
suite that set only ``--db`` read the files in the developer's own ``~/.meshterm``.
For this reason, a contact named ``[/]Bob``, which a person added by hand on one
machine, could make this suite crash on that machine and pass on all other machines.
``$MESHTERM_HOME`` moves the whole directory, and a test must use it.
"""
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, and report this as a usage error.
_REFUSES_JSON = {("specimen",), ("emulate",)}
# -- 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 has colour, because stdout is more often a pipe 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. A changed name breaks a script with no warning."""
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
"""There are no borders, boxes, or header rules. The command that is 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
"""The column padding must not become invisible spaces at the end of each record."""
for line in result_lines(run(command, *args)):
assert line == line.rstrip(), repr(line)
def result_lines(result) -> list[str]: # noqa: ANN001
"""The stdout of the command as lines, without the blank line at the end.
``result.output`` is a third buffer, and both streams copy into it. Thus a check of a
stdout rule against it passes when the field appeared only on stderr. The check also
fails for the wrong reason when anything writes a log.
"""
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 apply to the whole CLI, not to the six commands that a person listed by hand.
A command that needs arguments fails with a usage error when it runs with none. This
is harmless, and it still proves that the command wrote nothing wrong. A command that
can answer with no arguments runs for real. Both violations that shipped were in such
commands: the ``---`` in ``about``, drawn as a rule as wide as a console of 16384 cells,
and the ``[muted]`` tags around the text that ``config export-key`` must print.
"""
out = run(*_sweep(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 and not to a list of tools.
It reached two commands out of twenty and stopped, because each command that wanted it
had to state its whole answer again above the rendering. The same sweep that checks the
plain rules now runs each command a second time and reads the result. **Each line on
stdout is one complete document**, for a success and for an empty result. A command that
fails writes nothing on stdout.
"""
result = run("--json", *_sweep(leaf))
for line in result.stdout.splitlines():
json.loads(line) # raises, with the wrong line, if other text was written
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 exit status is the report. A caller branches on it, so a flag about how the answer
looks must not change it. This check stops the two faces from drifting apart, as they
did before. ``devices --json`` exited with ``0`` where the plain path exited with ``5``.
Thus a script could not find the difference between an empty scan and a full scan, even
when it asked the same question two times.
"""
assert run("--json", *_sweep(leaf)).exit_code == run(*_sweep(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
"""The output of ``specimen`` is the colour, so a JSON document of it has no meaning.
A clear refusal is the correct answer. The project gives the same answer for the map,
the dashboard, and the live feed, but these have no subcommand that can refuse. The
refusal is a usage error, so stdout stays empty. The sentence goes to the same place as
the sentence of each other failure.
"""
result = run("--json", *_sweep(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 must not start a full-screen session.
An interactive session cannot print a document. If the session started, the program
that waits to parse the document waits for ever.
"""
result = run("--json")
assert result.exit_code == exitcodes.USAGE
assert result.stdout == ""
def test_no_leaf_command_declares_a_short_option_the_globals_already_claim() -> None:
"""A short option of a leaf that a global option also uses is shadowed and cannot be used.
``_globals_first`` moves each token that matches a group option to a place before the
subcommand, where the user typed it. Thus ``trace -t Alice -p a1,d4`` gave ``a1,d4`` to
``--profile`` and failed with "no device profile named 'a1,d4'". Three commands
documented ``-p`` as the short form of ``--path``, and on all three it was not usable.
The test examines the real app and does not use a list, because the next collision will
be with a different letter.
"""
from typer.main import get_command
from meshterm.cli import app
root = get_command(app)
globals_ = {
opt
for param in root.params
for opt in getattr(param, "opts", ())
if opt.startswith("-") and not opt.startswith("--")
}
assert globals_, "the root group declares no short options — has the callback moved?"
def walk(command, prefix: tuple[str, ...] = ()) -> None: # noqa: ANN001
for name, sub in sorted(getattr(command, "commands", {}).items()):
here = (*prefix, name)
if getattr(sub, "commands", None):
walk(sub, here)
continue
for param in sub.params:
clash = globals_ & {o for o in getattr(param, "opts", ()) if o.startswith("-")}
assert not clash, f"{' '.join(here)} declares {sorted(clash)}, a global's own"
walk(root)
def test_version_answers_without_touching_a_device(run) -> None: # noqa: ANN001
"""``--version`` is a question about the program, not about a run.
The option is eager, so it answers before MeshTerm opens a database or looks for a
radio. The answer is bare: one field for a script to read. To know the version is a
success, so the exit status is ``0``.
"""
from meshterm import __version__
result = run("--version")
assert result.exit_code == exitcodes.OK
assert result.stdout.strip() == f"meshterm {__version__}"
def test_the_sweep_actually_covers_the_whole_command_surface() -> None:
"""A derived list is a guarantee only while it finds the commands.
If the walk breaks (for example, a Typer upgrade moves ``commands``), each parametrised
case becomes zero cases with no warning. Then the sweep passes and covers 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
def test_a_global_option_may_be_typed_after_the_subcommand(run) -> None: # noqa: ANN001
"""``meshterm contacts --json`` is what a person types, so it must work.
Click binds an option to the command that declares it. Thus a global option that the
callback declares is a parse error when it comes one word later. For an output format,
this order is wrong: in each tool that has an output format, ``-o json`` goes after the
verb. The failure was a usage error with no hint that the user only needed to move the
flag.
"""
after = run("contacts", "--json")
before = run("--json", "contacts")
assert after.exit_code == exitcodes.OK, after.output
assert after.stdout == before.stdout
def test_a_global_option_taking_a_value_moves_with_its_value(run) -> None: # noqa: ANN001
"""If ``--db`` moves to the front, the path must move with it. The path must not stay behind."""
assert run("contacts", "--sort", "name", "--json").exit_code == exitcodes.OK
def test_lifting_a_global_never_steals_a_subcommands_own_help(run) -> None: # noqa: ANN001
"""``contacts --help`` must stay the help of ``contacts``, not the help of the app.
``--help`` is on each command. A rule that moves "the options of the group" to the front
also moves it, and then the answer is for a different question.
"""
contacts_help = run("contacts", "--help").stdout
assert "contacts [OPTIONS]" in contacts_help
assert "--sort" in contacts_help # an option of contacts, not in the help of the app
app_help = run("--help").stdout
assert "COMMAND [ARGS]" in app_help
assert "--sort" not in app_help
def test_a_time_column_is_headed_by_its_fact_not_by_its_format(run) -> None: # noqa: ANN001
"""``--absolute`` changes the values under the heading, so the heading must stay the same.
A column with the heading ``AGE`` and the value ``2026-09-08T04:18:25-04:00`` has a wrong
heading. Each other time column names the fact (``HEARD``, ``RECORDED``), and this name is
correct for each form that the run asks for.
"""
run("chat", "send", "--to", "Alice", "hello")
relative = result_lines(run("chat", "history", "--to", "Alice"))[0].split()
absolute = result_lines(run("chat", "history", "--to", "Alice", "--absolute"))[0].split()
assert relative == absolute
assert "AGE" not in relative
# -- listings -------------------------------------------------------------------------
def test_contacts_names_every_node_bare_and_ages_every_time(run) -> None: # noqa: ANN001
"""The two rules that changed direction, checked on the listing for which they changed.
A name is bare, because the alignment now makes it one field. ``HEARD`` is an age,
because the user opens this listing to ask "was it heard recently?". An ISO instant
makes the user do arithmetic to answer. ``--absolute`` shows the instants again, for a
user 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"
# Read the lane at the position of its heading. Do not count words, because a value can
# have a space (the `Lakeside BBS` of the simulator is a `room server`). The alignment,
# not the whitespace, separates the fields.
heard = header.index("HEARD")
for line in records:
assert not line.startswith('"')
age = line[heard:].split()[0]
assert age in ("now", "never") or age[-1] in "mhdw"
# `--absolute` asks for a different form of time, not for one fact less. Thus `never`
# stays: a node that was never heard has no instant to print.
header, *absolute = result_lines(run("--absolute", "contacts"))
heard = header.index("HEARD")
for line in absolute:
stamp = line[heard:].split()[0]
assert stamp in ("-", "never") or stamp[:4].isdigit()
def test_contacts_never_elides_a_key(run) -> None: # noqa: ANN001
"""A caller cannot give a truncated key 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 that ``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 label for the user: `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 is checked on each setting, not only on the two easy settings.
A user can write ``config show > f`` and then give ``f`` back as input. This is the
normal use of a dump, and a failure gives no warning. An empty string that was printed as
``""`` was parsed back as the two quote characters. The next dump looked the same, so
nothing showed that the setting was replaced. A refusal is acceptable here, because a
user cannot set a value that the firmware did not report. A value that parses to
something else is not acceptable.
"""
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 clear refusal is correct. A wrong value is not correct.
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 absent value must not make this claim.
``conceal`` already does not mask the absence token of the menu, for this reason. But
the token of the scripted face is ``-``, and ``conceal`` did not know it. Thus a radio
that never reported a PIN printed six bullets in a dump, and the user (or the bug report
where the user pasted the dump) read this as a PIN lock.
"""
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 and an absent value is
# not. Each surface says which glyph it uses for an absent value.
assert conceal("1234", absent=script.NONE) == MASK_MARK * 6
assert conceal(script.NONE, absent=script.NONE) == script.NONE
assert conceal("?") == "?"
# The two surfaces must not disagree about the same radio: `show` prints bullets only
# where `get` has a value 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. If the output repeats the key, the caller must remove it."""
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 shows ``5 s``, which has a unit that ``preferences set`` does not accept."""
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
"""This is the most important of the changes of direction, because of the project lexicon.
A path is a spec that the user writes and can paste into ``--path``. Thus it stays
separated by commas, and it round-trips. A route is what a walk did. It is not a spec,
and a route with commas promised a round trip that it does not have. Our node has a
name like each other hop. The ``★`` of the menu says "you already know who this is".
This is true for the user, and it is not true for a person who 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 has one record for each hop. The hash joins it to the route line."""
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 has no channel slots: the command worked and found nothing.
``grep`` uses the same status for "found nothing" and "worked". This is the only reason
that :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
"""This status comes from Click. The table names it, so the table 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
"""A retry does not help. The caller must change the request."""
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
"""The message is ``program: what went wrong``. A caller that parses stdout need not filter."""
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 the message goes where errors go.
These commands printed a red refusal on stdout and exited with 1. The colour and the
sentence were not the answer of the command, but they were in the text that the program
that read the answer received.
"""
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"),
(("records", "--width", "3"), "--width"),
(("records", "--width", "0"), "--width"),
(("records", "--width", "-1"), "--width"),
(("records", "--json", "--width", "3"), "--width"),
],
ids=[
"contacts-sort",
"records-category",
"records-width-3",
"records-width-0",
"records-width-negative",
"records-width-3-json",
],
)
def test_a_value_outside_a_closed_set_is_refused_rather_than_ignored(run, args, flag) -> None: # noqa: ANN001
"""A typo in an option with a closed set of values was answered as a different question.
``--sort bogus`` sorted by the default with no warning. ``--category long_hual`` selected
all the disciplines with no warning, and the exit status was ``5``, the code for a real
empty result. A caller that branched on ``5`` could not find the difference between
"there are no records" and "you misspelled the discipline". This is the worst answer
that 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`` says which radio to use. If it cannot be kept, no default replaces it.
An unknown name went to the normal discovery. Thus a scheduled ``--profile yagi config
advert`` with a typo transmitted from the companion that was attached, whichever it was.
This is the one failure where a silent error 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 status gave the wrong answer.
``3`` means that nothing was transmitted, and the user must look at what is plugged in.
``4`` means that MeshTerm reached the radio and the operation failed, so a retry is
reasonable. A ``--port`` that could not open gave ``4``, with the message "the connection
to the device was lost". There was never a connection. The ``case $?`` recipe in the
manual branches on this difference.
"""
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
@pytest.mark.parametrize(
"args",
[
("repeater-admin", "Nobody", "get", "name"),
("regions", "Nobody"),
("regions", "Alice"),
("courier", "queue", "Nobody", "hi"),
("courier", "queue", "Alice", "hi", "--at", "25:99"),
("tx-optimize", "--path", "Yagi-Repeater", "--samples", "1"),
],
ids=[
"unknown-admin-node",
"unknown-regions-node",
"regions-of-a-companion",
"unknown-courier-contact",
"bad-at-time",
"tx-optimize-one-hop-path",
],
)
def test_a_bad_argument_is_never_reported_as_a_device_failure(run, args) -> None: # noqa: ANN001
"""``4`` tells a caller that the radio answered and that a retry is worth a try.
In each of these cases, the argument is wrong, and MeshTerm decides this before it
transmits anything. If a caller retries on ``4``, it retries for ever and transmits
nothing. ``tx-optimize`` already gave the same answer for a missing password. These
commands did not yet do the same, and this includes the one-hop ``--path`` of
``tx-optimize``, which gave ``4`` for a path that never left the machine.
"""
result = run(*args)
assert result.exit_code == exitcodes.USAGE, result.output
assert result.stdout == ""
def test_a_node_that_answers_no_is_still_a_device_failure(run) -> None: # noqa: ANN001
"""This is the other side of the line: MeshTerm reached the radio, so a retry is a real option.
A refused admin login is not a bad argument, because the password can have changed. The
change of the status for the arguments above must not change the status of this case.
"""
result = run("repeater-admin", "Yagi-Repeater", "get", "name", "--password", "wrongpw")
assert result.exit_code == exitcodes.DEVICE, result.output
def test_a_repeaters_regions_on_both_faces(run) -> None: # noqa: ANN001
"""The output has the node that answered, if it relays unscoped floods, and the regions.
The output never has the ``*``.
"""
plain = run("regions", "Yagi-Repeater")
assert plain.exit_code == exitcodes.OK, plain.output
assert result_lines(plain) == [
"node Yagi-Repeater",
"unscoped yes",
"regions lakeside, lakeside-north, harbour",
]
document = json.loads(run("--json", "regions", "Yagi-Repeater").stdout)
assert document["node"]["name"] == "Yagi-Repeater"
assert document["unscoped"] is True
assert document["regions"] == ["lakeside", "lakeside-north", "harbour"]
def test_a_repeater_that_names_nothing_is_nothing_to_report(run, monkeypatch) -> None: # noqa: ANN001
"""The repeater answered, so this is not a failure. It relays no floods: exit status 5."""
from meshterm.core.connection import MockDevice
async def nothing(self, node): # noqa: ANN001, ANN202
return []
monkeypatch.setattr(MockDevice, "request_regions", nothing)
result = run("regions", "Yagi-Repeater")
assert result.exit_code == exitcodes.NO_RESULT
document = json.loads(run("--json", "regions", "Yagi-Repeater").stdout)
assert document["unscoped"] is False and document["regions"] == []
def test_a_repeater_that_never_answers_is_a_device_failure(run, monkeypatch) -> None: # noqa: ANN001
"""MeshTerm sent the request and nothing came back. A retry is a real option: exit status 4."""
from meshterm.core.connection import DeviceCommandError, MockDevice
async def silent(self, node): # noqa: ANN001, ANN202
raise DeviceCommandError(f"{node.name!r} did not answer the regions request.")
monkeypatch.setattr(MockDevice, "request_regions", silent)
result = run("regions", "Yagi-Repeater")
assert result.exit_code == exitcodes.DEVICE
assert result.stdout == ""
assert "did not answer" in result.stderr
def test_an_empty_result_writes_nothing_to_stdout_at_all(run) -> None: # noqa: ANN001
"""There is no "no channels configured" line. The status says 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`` won with no warning. Thus a scheduled ``--port COM7 --mock info`` reported on
a simulator, and the report looked as if it came from the radio. Two claims that
disagree about which device to use are a bad command line. They are not a question of
precedence.
"""
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
"""The CLI dropped ``ack``, and the reason covered only half of the case.
An acknowledgement is not the answer. A caller that redirects stdout must get only the
answer, but stdout is not the only stream. stderr follows this rule, and it also gives a
person who waits for a ``config restore`` of five minutes the ✓ for each setting. Before,
the person could not see these marks.
"""
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.
The count repeats what the listing above showed and what ``$?`` says, so it must not go
into a redirect. A person wants it after a command that scrolled past on the terminal.
"""
result = run("records")
assert result.exit_code == exitcodes.NO_RESULT
assert result.stdout == ""
assert "records stored" in result.stderr
@pytest.mark.parametrize("width", ["1", "2", "4"])
def test_every_real_hash_width_is_accepted(run, width) -> None: # noqa: ANN001
"""The refusal of the widths that do not exist must not refuse the three that exist."""
result = run("records", "--width", width)
assert result.exit_code == exitcodes.NO_RESULT, result.stderr
assert "records stored" in result.stderr
def test_the_simulator_is_listed_without_empty_brackets(run) -> None: # noqa: ANN001
"""``--mock devices`` named the simulator ``… (nothing transmits) ()``: a port it lacks."""
result = run("devices")
assert result.exit_code == exitcodes.OK, result.stderr
assert "the built-in simulator (nothing transmits)" in result.stdout
assert "()" not in result.stdout
#: The text of a source docstring that goes into ``--help`` when Typer uses the docstring:
#: a Sphinx role, the double backticks of reST, or a path into the package.
_SOURCE_NOTES = re.compile(r":(func|mod|class|meth|attr|data):`|``|meshterm/[\w/]+\.py")
@pytest.mark.parametrize("leaf", _leaf_commands(), ids=lambda leaf: "-".join(leaf))
def test_every_command_help_is_written_for_the_terminal(run, leaf) -> None: # noqa: ANN001
"""A help page is for a person at a prompt who asks what a command does.
``platform`` and ``specimen`` printed their whole docstrings. These are notes for a
person who reads ``cli.py``, with Sphinx roles and source paths. The same markup also
cut their one-line summaries short in ``meshterm --help``.
"""
result = run(*leaf, "--help")
assert result.exit_code == exitcodes.OK, result.output
assert not _SOURCE_NOTES.search(result.output), result.output
def test_the_command_list_has_no_source_notes(run) -> None: # noqa: ANN001
"""The one-line summaries in ``meshterm --help`` come from the same help text."""
result = run("--help")
assert not _SOURCE_NOTES.search(result.output), result.output
for line in result.output.splitlines():
if line.split()[:1] in (["platform"], ["specimen"]):
assert not line.rstrip().endswith("..."), f"summary cut short: {line!r}"
# -- the simulator's own history ------------------------------------------------------
def test_mock_records_into_a_database_of_its_own(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A simulated radio gets a simulated history, so a demo run does not change the real mesh data.
The simulator is a fake radio, not a fake MeshTerm. Before, the adverts of the simulator
were written to ``meshterm.db`` like each other message from a companion. Then its
invented contacts were in the mesh walk, the dashboard, and the map of the real mesh. The
run still stores its history, because the tools of a ``--mock`` session read it to see
what they heard. But the history goes to a different file.
"""
from meshterm.cli import app
home = tmp_path / "home"
monkeypatch.setenv("MESHTERM_HOME", str(home))
result = CliRunner().invoke(app, ["--mock", "info"])
assert result.exit_code == exitcodes.OK, result.output
assert (home / "meshterm-mock.db").exists()
assert not (home / "meshterm.db").exists()
def test_an_explicit_db_still_wins_under_mock(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``--db`` names one exact database, and the simulator does not override it.
Unlike ``--port``, ``--db`` does not contradict ``--mock``. The place where a run stores
its history and the radio that it uses are different questions. Thus this is a rule of
precedence and not a usage error. The ``run`` fixture of this suite also depends on it.
"""
from meshterm.cli import app
home = tmp_path / "home"
monkeypatch.setenv("MESHTERM_HOME", str(home))
named = tmp_path / "named.db"
result = CliRunner().invoke(app, ["--mock", "--db", str(named), "info"])
assert result.exit_code == exitcodes.OK, result.output
assert named.exists()
assert not (home / "meshterm-mock.db").exists()
assert not (home / "meshterm.db").exists()
# -- devices under --mock -------------------------------------------------------------
def test_mock_devices_scans_nothing_and_lists_the_simulator(
run, # noqa: ANN001
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``--mock`` promises no real hardware, so the list has the simulator and there is no scan.
The test replaces both discovery entry points with functions that fail with a clear
error, because the tool must not call either one. The one row that the tool lists names
``--mock`` as its target, because this flag selects this device. ``--port`` and ``--ble``
do the same for the other devices.
"""
from meshterm.tools import devices as tool
def never(*args, **kwargs): # noqa: ANN002, ANN003, ANN202
raise AssertionError("a --mock session scanned for real hardware")
monkeypatch.setattr(tool, "discover_devices", never)
monkeypatch.setattr(tool, "discover_all", never)
result = run("devices")
assert result.exit_code == exitcodes.OK
assert "--mock" in result.stdout and "mock" in result.stdout
machine = run("--json", "devices")
assert machine.exit_code == exitcodes.OK
(row,) = json.loads(machine.stdout)
assert row["target"] == "--mock" and row["transport"] == "mock"
# -- the map --------------------------------------------------------------------------
def test_the_map_has_no_cli_command_at_all(run) -> None: # noqa: ANN001
"""A picture has no scripted face. The nodes that have a position 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
"""The help from Click has no boxed panels and no colour. The status table is 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