Files
MeshTerm/tests/test_script_output.py
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

577 lines
25 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for the plain CLI's output language (:mod:`meshterm.ui.script`).
A person at a prompt reads the plain face. ``--json`` is for the machine. Each rule that
makes the plain face easy to read can be tested: no colour, no wrapping, no frames, bare
names that cannot break a record, relative ages, arrows for a route and commas for a path,
one token for an absent value, and one documented exit status for each outcome. These
tests are the enforcement point for :mod:`meshterm.ui.script`, in the same way as
``test_gallery`` is for the screens, and :mod:`tests.test_report` is for the seam above
them.
"""
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. No output of the scripted console can match this.
_ANSI = re.compile(r"\x1b\[")
def _rendered(*renderables: object) -> str:
"""Print ``renderables`` through the scripted console and return the output."""
buffer = io.StringIO()
console = script.console(buffer)
for renderable in renderables:
console.print(renderable)
console.file.flush()
return buffer.getvalue()
# -- names ---------------------------------------------------------------------------
@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"),
],
)
def test_a_name_is_bare_because_alignment_is_the_delimiter(value: str, expected: str) -> None:
"""The quotes are retired. A column that is aligned is already one field to the eye.
The quotes were there so that a name with a space or a comma stayed one field for the
code that split the line. That code reads JSON now. In plain text, the padding shows
where a field ends. Thus the user gets back the six cells that the quotes used in each
row, and a name shows as the name that the node broadcast.
"""
assert script.name(value) == expected
def test_a_name_still_cannot_end_the_record_it_sits_in() -> None:
"""The escaping under the quoting stays. This was the half that was necessary.
A node broadcasts its own name, so each name here is remote data. The alignment does not
stop a newline, a tab, or an ESC in a name from ending the record too early. An ESC in
a name is a live colour sequence that goes into the file of the caller. The rule "not a
single escape sequence" exists to prevent this.
"""
assert script.name("two\nlines") == "two\\nlines"
assert script.name("tabbed\there") == "tabbed\\there"
assert script.name("bell\a") == "bell\\x07"
assert "\x1b" not in script.name("esc\x1b[31m")
@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" must stay one line.
A body with a newline ended its record too early. The remainder was indented under the
other columns, and it looked like a second record with an empty ``TIME``. The body is
also the one field that a stranger fills in, so this message is not hypothetical. The
function does not change quotes in the body. Nothing wraps the body, so there is
nothing to escape from.
"""
assert script.text(body) == expected
assert "\n" not in script.text(body)
@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". These are different facts.
Seven of the ten places that printed a name made the first claim by accident. Thus the
same node with no name showed as ``""`` in one listing and ``-`` in the next. One helper
now gives the answer. The machine face needs the same distinction: an absent name is
``null``, an empty name is ``""``, and a field that makes both the same cannot say
which one 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 in a form that 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 with an offset that we do not know. This is not a fact.
:func:`~meshterm.ui.widgets.age_seconds` uses the same rule for relative ages.
"""
assert script.stamp(None) == script.NONE
assert script.stamp(datetime(2026, 9, 7, 22, 22, 41)) == script.NONE
def test_a_time_is_an_age_unless_the_instant_is_itself_the_fact() -> None:
"""A time is an age by default. This rule changed, and this is the reason.
A person at a prompt who reads ``2026-09-07T19:58:53-04:00`` does arithmetic to answer
"recently?". This is the question that the person typed the command to ask. Thus an age
is now the default. :func:`~meshterm.ui.script.stamp` stays for three places where the
instant is the answer: the device clock, an appointment that ``--at`` sets, and the
``TIME`` column of a live capture itself (an age shows ``now`` in each row). It
also stays for each column under ``--absolute``.
"""
fresh = datetime.now(timezone.utc) - timedelta(seconds=5)
assert script.age(fresh) == "now"
assert script.stamp(fresh)[:4].isdigit()
@pytest.mark.parametrize(
"seconds,expected",
[
(0, "now"),
(59, "now"),
(60, "1m"),
(3599, "59m"),
(3600, "1h"),
(86400, "1d"),
(604800, "1w"),
],
)
def test_the_age_ladder_steps_where_a_person_would_step(seconds: int, expected: str) -> None:
"""``now``, ``5m``, ``3h``, ``2d``, ``4w``: the ladder of the columns in the menu.
The code calls :func:`~meshterm.ui.widgets.format_age` and does not derive the ages
again. Thus the two faces of the same age cannot become different.
"""
assert script.age(datetime.now(timezone.utc) - timedelta(seconds=seconds)) == expected
def test_an_age_reads_an_unknown_offset_as_absent_exactly_as_a_stamp_does() -> None:
"""A naive datetime is a time with an offset that we do not know. This is not a fact."""
assert script.age(None) == script.NONE
assert script.age(datetime(2026, 9, 7, 22, 22, 41)) == script.NONE
def test_never_is_a_value_and_the_absent_token_is_not() -> None:
"""A node that was never heard is a fact. A row with no such time is not a fact.
If both cases show ``-``, then "we have not heard from it" and "this type of row has no
heard time" look the same. The column exists to show this difference.
"""
assert script.age(None, absent="never") == "never"
assert script.age(None) == script.NONE
@pytest.mark.parametrize(
"seconds,expected",
[(42, "42s"), (360, "6m"), (125, "2m 5s"), (93784, "1d 2h"), (3600, "1h"), (-125, "2m 5s")],
)
def test_a_duration_reads_in_two_units_at_most(seconds: int, expected: str) -> None:
"""``93784`` is a number that nobody can keep in mind. ``1d 2h`` is the same fact.
The duration has two adjacent units, the largest first. Thus the user sees the
magnitude at a glance, and the precision is not more than the user can use. It is
always a gloss beside the raw figure. The key says what the number counts, and a caller
that reads the key must still find a number under it.
"""
assert script.duration(seconds) == expected
def test_a_location_is_one_field_because_it_is_one_fact() -> None:
"""A coordinate pair is the text that goes into the search box of a map, without change.
Two columns also give two ``-`` for each node that never shared a position. This is
worse than one ``-``.
"""
assert script.location(45.50190, -73.56740) == "45.50190,-73.56740"
assert script.location(45.5, None) == script.NONE
assert script.location(None, None) == script.NONE
# -- the absent token -----------------------------------------------------------------
def test_one_token_stands_for_every_kind_of_absence() -> None:
"""The user learns ``-`` one time, and never sees an em dash, an ``n/a``, or a blank.
``never`` is the one word that is not an absence. It is a value, and the age lane spells
it (refer to the ladder tests above).
"""
assert script.NONE == "-"
assert script.number(None) == script.NONE
assert script.route([]) == script.NONE
assert script.spec([]) == script.NONE
def test_number_formats_with_its_spec_when_there_is_one() -> None:
"""The function formats a number that is present. Only an absent number becomes the token."""
assert script.number(2.64, "+.1f") == "+2.6"
assert script.number(-1.5, "+.1f") == "-1.5"
assert script.number(7) == "7"
# -- routes and paths ------------------------------------------------------------------
def test_a_route_is_drawn_with_arrows_and_a_path_keeps_its_commas() -> None:
"""A route is walked and a path is typed, and the output shows the difference.
Both were separated by commas in the past. A route looked like text that you can paste
back into ``--path``, but you cannot, because the names are in it. Thus the comma
promised a round trip that only the spec has. The arrow says "this is a picture", and
it shows the direction in which the packet travelled.
"""
assert (
script.route([("Alice", "3d"), ("Yagi-Repeater", "f2")])
== "Alice (3d) → Yagi-Repeater (f2)"
)
assert script.spec(["a1", "d4", "a1"]) == "a1,d4,a1"
def test_a_route_names_our_own_node_like_any_other_hop() -> None:
"""The route has no star. The reason is the person who reads the line.
The menu draws our node as ``★``, because the user does not need to be told which node
is theirs. A route line is often read from a file by somebody who was not at the prompt
when the command ran. For that person, the star names nothing.
"""
line = script.route([("MockCompanion", "00"), ("Alice", "3d"), ("MockCompanion", "00")])
assert "★" not in line
assert line.startswith("MockCompanion (00)")
assert line.endswith("MockCompanion (00)")
def test_a_route_hop_never_shows_an_empty_pair_of_parentheses() -> None:
"""Half an identity prints as the half that is known: a name alone, or a hash alone.
:class:`~meshterm.ui.pathline.PathLine` in the menu labels an unresolved hop in the same
way. Both halves are optional because the history often knows only one half.
"""
assert script.route([("Alice", None)]) == "Alice"
assert script.route([(None, "3d")]) == "3d"
assert script.route([(None, None)]) == script.NONE
def test_the_arrow_is_what_makes_the_quoting_unnecessary() -> None:
"""The claim moved. The quotes made a comma safe, and now nothing must do this.
A comma in the name of a node cannot be read as a hop boundary now, because the hop
boundary is not a comma.
"""
line = script.route([("Node, Inc", "3d"), ("Bob", "f2")])
assert line == "Node, Inc (3d) → Bob (f2)"
assert line.count("→") == 1
def test_a_spec_is_the_one_line_on_either_face_that_round_trips() -> None:
"""``--path`` takes the spec back, so the spec has no name, no arrow, and no space."""
assert script.spec(["a1b2", "d4e5", "a1b2"]) == "a1b2,d4e5,a1b2"
assert " " not in script.spec(["a1", "d4"])
# -- live streams ----------------------------------------------------------------------
def test_a_stream_pins_its_lanes_because_it_cannot_measure_them() -> None:
"""A live capture cannot measure its columns, because it has not seen the records yet.
:func:`~meshterm.ui.script.columns` sets the size of each lane to its widest value after
all the records are in. ``monitor`` prints a row at the moment when a packet arrives,
and the next row can be twice as wide. Thus these two streams were joined with a gutter
and had no alignment. The quoting was the last thing that kept the alignment.
"""
lanes = script.stream(("TIME", 19), ("NODE", 8), ("SNR_DB", 6), right=("SNR_DB",))
assert lanes.header == "TIME NODE SNR_DB"
assert lanes.record("2026-09-08T02:25:45", "a1b2c3d4", "+7.0") == (
"2026-09-08T02:25:45 a1b2c3d4 +7.0"
)
def test_a_streamed_value_wider_than_its_lane_overruns_rather_than_lying() -> None:
"""One row of many is wider, and the stream does not elide anything.
This is the reason that the one field with no limit goes last (the name of a node, or
the body of a message). Then it pushes no other field.
"""
lanes = script.stream(("NODE", 8), ("NAME", 6))
assert lanes.record("a1", "A Very Long Node Name") == "a1 A Very Long Node Name"
# -- the console ----------------------------------------------------------------------
def test_the_scripted_console_emits_no_escape_sequence_at_all() -> None:
"""No colour, no bold, no dim: ``stdout`` is more often a pipe 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 each name printed here is remote data.
Rich reads ``[...]`` as a style tag, unless the code tells it not to. This caused two
different bugs. ``[bold]Loud`` printed as ``Loud``. This was a silent corruption, and
the text was no longer the string that identifies the node to the person who reads the
output. ``[/]Bob`` raised ``MarkupError`` and stopped the whole command. ``:fire:`` is
the same hazard in another parser. These cases are not hypothetical: a name is the one
field that a stranger controls.
"""
table = script.columns("NAME", "TYPE")
table.add_row(script.name(name), "node")
record = _rendered(table).splitlines()[1]
assert record.startswith(script.name(name)), record
def test_the_scripted_console_never_wraps_a_record_onto_a_second_line() -> None:
"""A record is a line. If it wraps, half of its fields are 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:
"""If the last column has padding to its width, each line has invisible spaces at the end."""
table = script.columns("NAME", "TYPE")
table.add_row(script.name("a-long-name"), "node")
table.add_row(script.name("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, but it must not remove the padding between.
Rich writes a rendered row one segment at a time and flushes after each segment. If a
wrapper trims at the flush, it removes the spaces that make the columns into columns
(refer to :class:`meshterm.ui.script._Trimmed`).
"""
table = script.columns("NAME", "TYPE", "PKTS", right=("PKTS",))
table.add_row(script.name("Alice"), "node", "12")
header, record = _rendered(table).splitlines()
assert header.split() == ["NAME", "TYPE", "PKTS"]
assert record.split() == ["Alice", "node", "12"]
# The text is written in full, not as `" " * script.GUTTER`. An assertion that reads
# the constant that it checks becomes `" " in record` when the gutter becomes narrower.
# The gutter exists to prevent this state.
assert "Alice node" in record
def test_columns_align_so_every_record_splits_the_same_way() -> None:
"""Each field is in the column of its heading, for any widths."""
table = script.columns("NAME", "TYPE")
table.add_row(script.name("Yagi-Repeater"), "repeater")
table.add_row(script.name("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")} # each TYPE value starts under its heading
def test_a_numeric_column_really_right_aligns() -> None:
"""The user can compare a column of magnitudes by eye only if the digits are aligned.
``Text.wrap`` of Rich returns early when ``overflow="ignore"``. This early return is
above the justify step, so ``overflow="ignore"`` removes ``justify="right"`` and gives
no warning. Each ``right=`` in the CLI did nothing until the columns used ``"crop"``.
``"crop"`` refuses to elide in the same way, and it still aligns. A check of the
position where a field starts cannot find this problem. Only a check of the end can.
"""
table = script.columns("NAME", "PKTS", right=("PKTS",))
table.add_row(script.name("Alice"), "1174")
table.add_row(script.name("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 caller cannot give a short key back to ``--to`` or ``--path``.
The guard against elision and the guard against misalignment are the same setting.
Thus a change to one guard changes the other guard without a warning. This test
checks both.
"""
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
def test_the_trimmer_keeps_the_gutter_when_the_stream_flushes_between_columns() -> None:
"""The legacy-Windows renderer of Rich writes a row one segment at a time and flushes each.
``LegacyWindowsTerm.write_text`` is ``write(text); flush()``. Thus on that path a flush
occurs between two columns as often as at the end of a line. A wrapper that emptied its
held run at the flush destroyed each gutter in real use. The tests in the process
continued to pass, because they see one write and one flush for each ``print``. This is
not hypothetical: the code shipped with this bug. This test drives the stream in the
same way as that renderer.
"""
buffer = io.StringIO()
stream = script._Trimmed(buffer)
for segment in ("Alice", " ", "node", " ", "12", "\n"):
stream.write(segment)
stream.flush()
assert buffer.getvalue() == "Alice node 12\n"
def test_the_trimmer_still_drops_the_run_that_ends_a_line() -> None:
"""The other half of the rule: the trimmer holds padding, and it must still remove it.
The trimmer writes held whitespace when text follows it on the line. It removes the
whitespace at the newline and at the end of the stream. Otherwise each record ends with
the invisible padding of its last column.
"""
buffer = io.StringIO()
stream = script._Trimmed(buffer)
for segment in ("row", " ", "\n", "next", " "):
stream.write(segment)
stream.flush()
assert buffer.getvalue() == "row\nnext"
def test_a_value_wider_than_the_console_is_cut_rather_than_elided() -> None:
"""After :data:`~meshterm.ui.script.WIDTH` cells the value is cut. It must not become "…".
The user cannot see the difference between an elision and a value that really ends
there. A key that the user reads from a listing then goes back into ``--to`` with an
error that is hard to see. A key of 64 hex digits never reaches the overflow path, so
only a value that is wider than the limit tests the guard at that place.
"""
table = script.columns("KEY")
table.add_row("d" * (script.WIDTH + 500))
assert "…" not in _rendered(table)
def test_an_error_stays_greppable_when_stderr_is_not_a_terminal() -> None:
"""An error stays easy to find with grep when stderr is not a terminal.
A sentence that is broken across three lines is a sentence that nothing can find. Rich
uses 80 cells when it cannot measure the destination. Thus ``2> errors.log`` wrapped
each message, and ``grep 'not already connected'`` found nothing, because a newline was
in the middle of the words. In a real terminal, the code does not change the width. A
progress bar of 16384 cells is not a progress bar.
"""
import sys
buffer = io.StringIO()
stderr, sys.stderr = sys.stderr, buffer
try:
console = script.stderr_console()
console.print("meshterm: " + "the connection was not already connected " * 6)
finally:
sys.stderr = stderr
assert len(buffer.getvalue().splitlines()) == 1
# -- framing --------------------------------------------------------------------------
def test_a_panel_gives_up_its_border_and_its_title() -> None:
"""No frames: the command that the user 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:
"""If a shared table arrives with a box, the output has no frame."""
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:
"""The code removes the frames at any depth in a shared renderable."""
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.name("Alice"), "node")
assert "─" not in _rendered(table)
# -- key/value listings ---------------------------------------------------------------
def test_pairs_print_one_fact_per_line_with_no_header() -> None:
"""The form of ``sysctl -a``: 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:
"""Quotes are for a name that shares 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_the_exit_statuses_are_the_numbers_a_caller_hard_codes() -> None:
"""``case $? in 3)`` uses the digits, so the digits are the contract.
The test states the digits as literals on purpose. The assertion
``set(MEANINGS) == {OK, FAILURE, …}``, or ``len(set(d)) == len(d)``, cannot fail when
someone changes the numbers of the constants. A dict has no duplicate keys, so if two
statuses have the same number, the dict loses a row, and both sides of the comparison
lose the same row together. When a change gave ``NO_DEVICE`` the number ``4``, all of
these assertions stayed green, and status 3 was not in ``--help``.
"""
assert (exitcodes.OK, exitcodes.FAILURE, exitcodes.USAGE) == (0, 1, 2)
assert (exitcodes.NO_DEVICE, exitcodes.DEVICE, exitcodes.NO_RESULT) == (3, 4, 5)
assert sorted(exitcodes.MEANINGS) == [0, 1, 2, 3, 4, 5]
assert all(meaning for meaning in exitcodes.MEANINGS.values())
def test_the_help_epilog_names_every_status() -> None:
"""A caller must not need 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