Files
MeshTerm/tests/test_script_output.py
T
Jean-Pierre MartineauandClaude Opus 5 98c635a62e Refuse the wrong answer instead of quietly giving it
Four places where the CLI answered a question the caller had not asked.

`--profile yagi` naming no profile fell through to ordinary discovery, so
a typo in a scheduled advert transmitted from whichever radio happened to
be attached. Naming a profile is a claim about which device; an
unkeepable claim is a bad argument, not a default.

`--sort bogus` sorted by the default and `--category long_hual` 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 "no records"
from "you misspelled the discipline". `ContactsSort.from_name` still
forgives an unknown name, because a saved preference from an older build
must not wedge the menu's list; the command line refuses instead.

A `--port` that would not open reported exit 4 and "the connection to the
device was lost", describing a connection there had never been. Every
failure while acquiring the device is the other status: nothing was
transmitted. It now says which port, and why.

A message body could hold a newline, which ended its record early and
left the remainder indented under the other columns as a second record
with an empty TIME — the one field a stranger fills in, breaking the one
rule the format has. script.oneline folds it flat.

And an unnamed node read as `""` in one listing and `-` in the next:
seven of the ten places that print a name were claiming "called nothing"
where they meant "never said". script.name draws the distinction once,
which is also the one the JSON rendering will need.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:31:22 -04:00

366 lines
15 KiB
Python

"""Tests for the scripted CLI's output language (:mod:`meshterm.ui.script`).
The CLI is read by ``awk``, and every rule that makes that possible is testable: no
colour, no wrapping, no framing, quoted names, absolute times, one token for absent, one
documented exit status per outcome. These are the enforcement point for
:mod:`meshterm.ui.script` the way ``test_gallery`` is for the screens.
"""
from __future__ import annotations
import io
import re
from datetime import datetime, timedelta, timezone
import pytest
from rich.console import Group
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from meshterm.core import exitcodes
from meshterm.ui import script
#: Any ANSI escape sequence. Nothing the scripted console emits may match this.
_ANSI = re.compile(r"\x1b\[")
def _rendered(*renderables: object) -> str:
"""Print ``renderables`` through the scripted console and return what came out."""
buffer = io.StringIO()
console = script.console(buffer)
for renderable in renderables:
console.print(renderable)
console.file.flush()
return buffer.getvalue()
# -- quoting -------------------------------------------------------------------------
@pytest.mark.parametrize(
"value,expected",
[
("Yagi-Repeater", '"Yagi-Repeater"'),
("YUL Cartierville", '"YUL Cartierville"'),
("Node, Inc", '"Node, Inc"'),
('He said "hi"', '"He said \\"hi\\""'),
("back\\slash", '"back\\\\slash"'),
("", '""'),
(None, '""'),
],
)
def test_quote_makes_any_name_exactly_one_field(value: str | None, expected: str) -> None:
"""A name is quoted and its quotes and backslashes escaped, whatever it holds.
This is what lets a listing be split: an unquoted ``YUL Cartierville`` is two fields,
and an unquoted ``Node, Inc`` is two hops of a path line.
"""
assert script.quote(value) == expected
def test_a_quoted_name_survives_a_round_trip_through_the_escaping() -> None:
"""The escaping is the ordinary backslash convention, so the value reads back."""
for name in ('a "quoted" name', "back\\slash", 'both\\"kinds'):
body = script.quote(name)[1:-1]
assert body.replace('\\"', '"').replace("\\\\", "\\") == name
@pytest.mark.parametrize(
"body,expected",
[
("line one\nline two", "line one\\nline two"),
("tabbed\there", "tabbed\\there"),
("carriage\rreturn", "carriage\\rreturn"),
("back\\slash", "back\\\\slash"),
('a "quoted" body', 'a "quoted" body'),
("plain", "plain"),
],
)
def test_a_message_body_can_never_end_its_own_record(body: str, expected: str) -> None:
"""``TEXT`` is the rest of the line, and "the rest of the line" has to stay one line.
A body holding a newline used to end its record early and leave the remainder indented
under the other columns, reading as a second record with an empty ``TIME``. The body is
also the one field a stranger fills in, so this is not a hypothetical message. Quotes
inside it are left alone: nothing wraps it, so nothing needs escaping from.
"""
assert script.oneline(body) == expected
assert "\n" not in script.oneline(body)
def test_a_name_holding_a_newline_cannot_end_its_record_either() -> None:
"""The same hazard one field over — and a name is broadcast by its own node."""
assert script.quote("two\nlines") == '"two\\nlines"'
@pytest.mark.parametrize("absent", [None, ""])
def test_a_node_that_never_gave_a_name_reads_as_absent_not_as_empty(absent: str | None) -> None:
"""``""`` says "called nothing"; ``-`` says "never said". They are different facts.
Seven of the ten places that printed a name made the first claim by accident, so the
same unnamed node read as ``""`` in one listing and ``-`` in the next. One helper now
answers it, and the distinction is the one the JSON rendering needs too — an absent
name is ``null``, an empty one is ``""``, and a field that flattened both cannot say
which it meant.
"""
assert script.name(absent) == script.NONE
assert script.name("Alice") == '"Alice"'
# -- timestamps ----------------------------------------------------------------------
def test_stamp_is_absolute_local_iso_to_the_second() -> None:
"""A time is printed as something a script can sort and subtract."""
when = datetime(2026, 9, 7, 22, 22, 41, tzinfo=timezone.utc)
text = script.stamp(when)
assert datetime.fromisoformat(text) == when
assert text.endswith(when.astimezone().strftime("%z")[:3] + ":00")
def test_stamp_reads_an_unknown_offset_as_absent() -> None:
"""A naive datetime is a time whose offset we don't know, which is not a fact.
The same rule :func:`~meshterm.ui.widgets._age_seconds` applies to relative ages.
"""
assert script.stamp(None) == script.NONE
assert script.stamp(datetime(2026, 9, 7, 22, 22, 41)) == script.NONE
def test_no_relative_age_ever_reaches_the_scripted_output() -> None:
"""``3h`` is for a screen; the CLI's stamp is absolute even for a fresh time."""
assert script.stamp(datetime.now(timezone.utc) - timedelta(seconds=5)) != "now"
# -- the absent token -----------------------------------------------------------------
def test_one_token_stands_for_every_kind_of_absence() -> None:
"""A reader (and a parser) learns ``-`` once, and never meets an em dash or an n/a."""
assert script.NONE == "-"
assert script.number(None) == script.NONE
assert script.path([]) == script.NONE
def test_number_formats_with_its_spec_when_there_is_one() -> None:
"""A present number is formatted; only an absent one becomes the token."""
assert script.number(2.64, "+.1f") == "+2.6"
assert script.number(-1.5, "+.1f") == "-1.5"
assert script.number(7) == "7"
# -- path lines -----------------------------------------------------------------------
def test_a_path_line_quotes_each_node_and_hangs_its_hash_outside_the_quotes() -> None:
"""The CLI's path shape: ``"Name" (hash)``, hops joined by a bare comma."""
line = script.path([("Alice", "3d"), ("Yagi-Repeater", "f2")])
assert line == '"Alice" (3d),"Yagi-Repeater" (f2)'
def test_a_path_line_names_our_own_node_like_any_other() -> None:
"""No star: the glyph says "you already know this one", which is false of a parser."""
line = script.path([("MockCompanion", "a1"), ("Alice", "3d"), ("MockCompanion", "a1")])
assert "★" not in line
assert line.startswith('"MockCompanion" (a1)')
assert line.endswith('"MockCompanion" (a1)')
def test_a_path_line_prints_a_name_alone_when_no_hash_places_it() -> None:
"""An unidentified node has a name and nothing else; it does not get an empty ``()``."""
assert script.path([("Alice", None)]) == '"Alice"'
def test_a_path_line_splits_even_when_a_name_holds_a_comma() -> None:
"""The quoting is what makes the bare comma separator safe."""
line = script.path([("Node, Inc", "3d"), ("Bob", "f2")])
assert line.count(",") == 2 # the one inside the name, and the one separating hops
assert line == '"Node, Inc" (3d),"Bob" (f2)'
# -- the console ----------------------------------------------------------------------
def test_the_scripted_console_emits_no_escape_sequence_at_all() -> None:
"""Not a colour, not a bold, not a dim — ``stdout`` is a pipe more often than a screen."""
loud = Text.assemble(
("brand", "brand"), ("bold", "bold"), ("hex", "#ff8800"), ("reverse", "reverse")
)
assert not _ANSI.search(_rendered(loud))
@pytest.mark.parametrize(
"name",
["[bold]Loud", "[/]Bob", "[red]a[/red]", ":fire:Hot", "[[weird]]", "100% [done]"],
)
def test_a_node_name_is_never_read_as_rich_markup(name: str) -> None:
"""A node broadcasts its own name, so every name printed here is remote data.
Rich reads ``[...]`` as a style tag unless told not to. That made a name into two
different bugs: ``[bold]Loud`` printed as ``Loud`` — silently corrupted, and no longer
the string that identifies the node to whoever reads the output — and ``[/]Bob`` raised
``MarkupError`` and took the whole command down. ``:fire:`` is the same hazard one
parser over. Neither is hypothetical: a name is the one field a stranger controls.
"""
table = script.columns("NAME", "TYPE")
table.add_row(script.quote(name), "node")
record = _rendered(table).splitlines()[1]
assert record.startswith(script.quote(name)), record
def test_the_scripted_console_never_wraps_a_record_onto_a_second_line() -> None:
"""A record is a line: wrapping would put half its fields under the wrong headings."""
table = script.columns("KEY", "VALUE")
table.add_row("basemap_tilejson_url", "x" * 400)
lines = _rendered(table).splitlines()
assert len(lines) == 2 # the header and the one record
assert lines[1].endswith("x" * 400)
def test_the_scripted_console_leaves_no_trailing_whitespace() -> None:
"""Padding the last column to its width would put invisible spaces on every line."""
table = script.columns("NAME", "TYPE")
table.add_row(script.quote("a-long-name"), "node")
table.add_row(script.quote("b"), "repeater")
for line in _rendered(table).splitlines():
assert line == line.rstrip(), repr(line)
def test_the_gutter_still_separates_columns_after_the_trimming() -> None:
"""The trimmer holds trailing whitespace back; it must not eat the padding *between*.
Rich writes a rendered row segment by segment and flushes after each one, so a
wrapper that trimmed at flush would throw away the very spaces that make the columns
columns (see :class:`meshterm.ui.script._Trimmed`).
"""
table = script.columns("NAME", "TYPE", "PKTS", right=("PKTS",))
table.add_row(script.quote("Alice"), "node", "12")
header, record = _rendered(table).splitlines()
assert header.split() == ["NAME", "TYPE", "PKTS"]
assert record.split() == ['"Alice"', "node", "12"]
assert " " * script.GUTTER in record
def test_columns_align_so_every_record_splits_the_same_way() -> None:
"""Every field lands in its heading's column, whatever the widths."""
table = script.columns("NAME", "TYPE")
table.add_row(script.quote("Yagi-Repeater"), "repeater")
table.add_row(script.quote("Al"), "node")
header, *records = _rendered(table).splitlines()
starts = {line.index("re") if "repeater" in line else line.index("node") for line in records}
assert starts == {header.index("TYPE")} # every TYPE value opens under its heading
def test_a_numeric_column_really_right_aligns() -> None:
"""A column of magnitudes is only comparable by eye if the digits line up.
Rich's ``Text.wrap`` returns early on ``overflow="ignore"`` — and that early return
sits *above* the justify step, so asking for ``overflow="ignore"`` throws
``justify="right"`` away without a word. Every ``right=`` in the CLI was a no-op
until the columns asked for ``"crop"``, which refuses to elide just as firmly and
still aligns. Checking where a field *starts* cannot see this; only its end can.
"""
table = script.columns("NAME", "PKTS", right=("PKTS",))
table.add_row(script.quote("Alice"), "1174")
table.add_row(script.quote("Bob"), "3")
header, *records = _rendered(table).splitlines()
assert len({len(line) for line in records}) == 1, records
assert {len(line) for line in records} == {len(header)}
def test_no_column_elides_however_long_its_value_runs() -> None:
"""A key cut short is not something a caller can hand back to ``--to`` or ``--path``.
The guard against eliding and the guard against mis-alignment are the same setting,
so a change to one silently moves the other; both are pinned here.
"""
table = script.columns("KEY", "PKTS", right=("PKTS",))
table.add_row("d4" * 32, "7")
out = _rendered(table)
assert "…" not in out
assert "d4" * 32 in out
# -- framing --------------------------------------------------------------------------
def test_a_panel_gives_up_its_border_and_its_title() -> None:
"""No frames: the command the reader typed is the title."""
out = _rendered(*script.flatten(Panel(Text("body"), title="Trace summary")))
assert out.strip() == "body"
def test_a_table_gives_up_its_box_its_title_and_its_expansion() -> None:
"""A shared table that still arrives boxed is unframed on the way out."""
from rich import box
table = Table(title="Contacts", box=box.SIMPLE_HEAD, expand=True)
table.add_column("NAME")
table.add_row("Alice")
out = _rendered(*script.flatten(table))
assert "Contacts" not in out
assert not any(ch in out for ch in "─│┌└")
def test_a_group_is_unframed_member_by_member() -> None:
"""Framing is stripped however deep a shared renderable nests it."""
nested = Group(Panel(Text("one"), title="First"), Panel(Text("two"), title="Second"))
assert _rendered(*script.flatten(nested)).split() == ["one", "two"]
def test_no_rendered_line_is_a_box_drawing_rule() -> None:
"""No borders and no header rules — a listing is its header line and its records."""
table = script.columns("NAME", "TYPE")
table.add_row(script.quote("Alice"), "node")
assert "─" not in _rendered(table)
# -- key/value listings ---------------------------------------------------------------
def test_pairs_print_one_fact_per_line_with_no_header() -> None:
"""The ``sysctl -a`` shape: a key, the gutter, and the rest of the line as its value."""
out = _rendered(script.pairs([("name", "MockCompanion"), ("uptime_s", "93784")]))
assert [line.split(None, 1) for line in out.splitlines()] == [
["name", "MockCompanion"],
["uptime_s", "93784"],
]
def test_a_pairs_value_is_unquoted_because_it_is_the_rest_of_the_line() -> None:
"""Quoting is for a name sharing a line with other fields; here there are only two."""
out = _rendered(script.pairs([("name", "YUL Cartierville")]))
assert out.splitlines()[0].split(None, 1) == ["name", "YUL Cartierville"]
# -- exit statuses --------------------------------------------------------------------
def test_every_exit_code_is_documented() -> None:
"""A documented return value is half of what makes the CLI scriptable."""
codes = {
exitcodes.OK,
exitcodes.FAILURE,
exitcodes.USAGE,
exitcodes.NO_DEVICE,
exitcodes.DEVICE,
exitcodes.NO_RESULT,
}
assert set(exitcodes.MEANINGS) == codes
assert all(meaning for meaning in exitcodes.MEANINGS.values())
def test_the_codes_are_distinct_and_ok_is_zero() -> None:
"""The shell's own convention, and no two outcomes share a status."""
assert exitcodes.OK == 0
assert len(set(exitcodes.MEANINGS)) == len(exitcodes.MEANINGS)
def test_the_help_epilog_names_every_status() -> None:
"""A caller should not have to find the table in a README."""
from meshterm.cli import EXIT_STATUS_EPILOG
for code, meaning in exitcodes.MEANINGS.items():
assert f"{code} {meaning}" in EXIT_STATUS_EPILOG