diff --git a/CHANGELOG.md b/CHANGELOG.md index b514719..c29cc71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ one caveat SemVer makes for a leading zero: while the major version is still `0` ## [Unreleased] +### Fixed + +- **`meshterm records --width` refuses a width that doesn't exist.** Hash widths are 1, 2, + or 4. Any other number used to look like an empty result, so a typo and an empty + database gave the same answer, and `--width 0` was ignored and listed every width. Now + it's a usage error that names the three choices, like `--category` already was. +- **The simulator's row in `meshterm --mock devices` no longer ends in an empty `()`.** + The brackets hold a serial port, and the simulator doesn't have one. +- **`meshterm platform --help` and `meshterm specimen --help` read like the others.** They + used to print notes meant for someone reading the source code, markup and all, and cut + their summaries short in `meshterm --help`. Now each has one plain line. + ## [0.10.0] — 2026-09-25 **Regions, a uConsole with no bridge, and Bluetooth that works on Linux.** MeshTerm now diff --git a/meshterm/cli.py b/meshterm/cli.py index 0cc3cb2..5b4096c 100644 --- a/meshterm/cli.py +++ b/meshterm/cli.py @@ -603,7 +603,10 @@ def _report_no_windows_console(console: Console) -> None: console.print("try [accent]meshterm --help[/accent].") -@app.command(name="platform") +@app.command( + name="platform", + help="Show which platform MeshTerm runs as, and why", +) def platform_command() -> None: """Print the resolved platform and every input the resolution looked at. @@ -650,7 +653,10 @@ def platform_command() -> None: ) -@app.command(name="specimen") +@app.command( + name="specimen", + help="Print every mark, icon, and colour MeshTerm draws", +) def specimen_command() -> None: """Print the visual-language specimen for the active platform. diff --git a/meshterm/core/discovery.py b/meshterm/core/discovery.py index 046cc9e..d2bf644 100644 --- a/meshterm/core/discovery.py +++ b/meshterm/core/discovery.py @@ -273,6 +273,8 @@ class DiscoveredDevice: if self.is_spi: return f"{self.name or 'SPI radio'} ({self.port})" name = self.product or self.description or self.vendor_label or "Serial device" + if not self.port: # the simulator has nothing to point at, so no "()" + return name suffix = f"({self.port})" if name.endswith(suffix): # avoid "… (COM11) (COM11)" name = name[: -len(suffix)].rstrip() diff --git a/meshterm/tools/records.py b/meshterm/tools/records.py index 3358728..3a49729 100644 --- a/meshterm/tools/records.py +++ b/meshterm/tools/records.py @@ -26,6 +26,9 @@ from ..services.records import CATEGORIES, CATEGORY_BY_ID, Category from ..ui.script import NONE from .base import Tool, ToolResult, register +#: The hash widths a record can be set at: MeshCore's path hashes are 1, 2 or 4 bytes. +WIDTHS = (1, 2, 4) + if TYPE_CHECKING: # pragma: no cover - typing only from ..persistence.repository import DiscoveredPath from ..ui.fields import NodeRef @@ -183,7 +186,13 @@ class TrophyCaseTool(Tool): f"--category must be one of: {choices} (got {category!r})" ) tool_params["category"] = category - if width: + if width is not None: + # The same closed set as --category, for the same reason: `--width 3` + # found nothing and exited 5, indistinguishable from an empty database, + # and `--width 0` was falsy and so quietly listed every width. + if width not in WIDTHS: + choices = ", ".join(map(str, WIDTHS)) + raise typer.BadParameter(f"--width must be one of: {choices} (got {width})") tool_params["width"] = width run_tool_command(self, tool_params) diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 67eccef..8567bb3 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -566,8 +566,19 @@ def test_a_destructive_command_without_yes_is_a_silent_usage_error(run, args) -> [ (("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", ], - 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. @@ -754,6 +765,49 @@ def test_a_closing_message_goes_to_stderr_too(run) -> None: # noqa: ANN001 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 + """Refusing the widths that don't exist must not refuse the three that do.""" + 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 + + +#: What a docstring written for the source leaks into ``--help`` when Typer is left to +#: use it: a Sphinx role, reST's double backticks, 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 someone at a prompt asking what a command does. + + ``platform`` and ``specimen`` printed their whole docstrings — notes for whoever reads + ``cli.py``, Sphinx roles and source paths included — and the same markup 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 ------------------------------------------------------ diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 44a5e12..cd3aac4 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -14,7 +14,7 @@ import pytest from meshterm.core.config import DeviceProfile from meshterm.core.device_store import DeviceStore -from meshterm.core.discovery import DiscoveredDevice, discover_devices +from meshterm.core.discovery import TRANSPORT_MOCK, DiscoveredDevice, discover_devices from meshterm.core.selection import DeviceSelectionError, resolve_device @@ -112,6 +112,21 @@ def test_label_does_not_repeat_the_port() -> None: assert DiscoveredDevice("COM5", product="Wio SX1262").label == "Wio SX1262 (COM5)" +def test_a_device_with_no_port_gets_no_empty_brackets() -> None: + """The simulator has nothing to point at, so its label ends at its name, not in "()". + + The serial branch is also where every transport without one of its own lands, and it + appended ``({port})`` whether or not there was a port. + """ + sim = DiscoveredDevice( + transport=TRANSPORT_MOCK, + description="the built-in simulator (nothing transmits)", + name="simulator", + ) + assert sim.label == "the built-in simulator (nothing transmits)" + assert DiscoveredDevice(product="Wio SX1262").label == "Wio SX1262" + + def test_stable_id_precedence() -> None: """stable_id prefers serial number, then vid:pid, then the port name.""" assert DiscoveredDevice("COM5", serial_number="SN1", vid=1, pid=2).stable_id == "sn:SN1"