Diagnostics is prose, so it is a written page
The block is a title, a standfirst, sections and a list under each. That is a page of writing, not a screenful of rows, and the app already knows how to draw one: the About pages' markdown renderer. So the tool states its report as before and a third projection turns it into markdown, which is exactly what the report seam was built for -- no tool touched, no report changed, one new function beside the other two faces. Being a written page buys the two things a long block most needs and that a hand-built screen had neither of. Its ## headings are landmarks, so each pins to the top row while its own section scrolls under it and ^PgUp/^PgDn step section by section; and the PicoCalc lane picks up Sect up / Sect down for the same reason a grouped list does. AboutPage already does all of it, so the page is that page with one verb added. The frame comes back with it. The bare frame was there to keep a border off every line a mouse would drag through, which mattered while copying off the screen was the only way out; saving is the way out now, so the page can be a normal screen and the save can be advertised where this app advertises verbs -- the footer hint on a desktop, the lane's one free F3 slot on the console -- instead of in the page's own body, which belongs to the report. The answer moves to a popup with it: a path is worth reading carefully, a write can fail, and the page underneath is unchanged and still there when the popup goes. Saving markdown is the same decision as drawing it. One source is what the page renders and what the file holds, and an issue tracker renders it as written, with no fence and no apology. Every value is a code span, which is correctness and not taste: a Windows path is full of backslashes that markdown reads as escapes, and an error a stranger's firmware wrote can hold an asterisk. A value already holding a backtick gets a longer fence. Rows are a list and not a table, measured rather than assumed -- a markdown table's cells are cropped with an ellipsis at 53 columns, and a bug report whose one long value is cut is missing the half that mattered. A list item wraps and hangs under its own text at any width. Two smaller things fell out. A block may carry a caption, the heading a format that has headings draws above it -- plain and JSON have no use for one and ignore it. And the host block splits in two, because a "it looks wrong" report is about the terminal and its reader should not have to pass the processor architecture on the way to it. The command line is untouched: aligned records, because that face is a stream to grep and headings are furniture on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
41c2cf416b
commit
72f72b84cd
8 files changed
+532
-378
No files matched your search
+25
-18
@@ -19,12 +19,6 @@ one caveat SemVer makes for a leading zero: while the major version is still `0`
|
||||
are the questions an issue thread otherwise asks one at a time, and the person who hit the
|
||||
bug is the one least able to answer them.
|
||||
|
||||
- **The page is a bare frame** — no border, no header, no footer, no key hints. Not a
|
||||
style choice: a terminal is selected by dragging, so every one of those is a character
|
||||
the clipboard carries into the issue. A long value hangs under its own block rather than
|
||||
being cropped, which is the one place the page and the command line lay the same facts
|
||||
out differently.
|
||||
|
||||
- **Nothing in it is private.** No pairing PIN, no admin password, no channel secret, no
|
||||
private key, no position, and no contact's name or key. The mesh is described in
|
||||
**aggregate** — a row count per table and the span the observations cover — which is the
|
||||
@@ -42,22 +36,35 @@ one caveat SemVer makes for a leading zero: while the major version is still `0`
|
||||
somewhere, so a table added later starts being reported the day it lands — and the
|
||||
interesting count is always the table nobody expected to be full.
|
||||
|
||||
- **It saves to a file, for attaching rather than pasting.** `s` on the page writes
|
||||
`meshterm-diagnostics.txt` into the config directory beside the log, and says so on the
|
||||
page's own last line rather than in a dialog over the block you were about to read.
|
||||
- **It is one of the app's written pages.** The block is markdown, drawn through the same
|
||||
renderer behind the About pages and saved as the same document, so what you read and
|
||||
what you attach are one source. Its `##` headings are landmarks: each pins to the top
|
||||
row while its own section scrolls under it, and `^PgUp`/`^PgDn` step section by section.
|
||||
Every value is a code span, so a Windows path's backslashes and a firmware error's
|
||||
asterisks arrive as themselves rather than as markdown.
|
||||
|
||||
- **It saves, for attaching rather than pasting.** `s` writes `meshterm-diagnostics.md`
|
||||
into the config directory beside the log, and answers in a popup naming the path; a
|
||||
write that fails says so in the same popup, in red, and leaves the page readable. The
|
||||
key is advertised in the footer hint on a desktop and on the lane's free **F3** slot on
|
||||
the PicoCalc — never in the page's body, which belongs to the report.
|
||||
`meshterm diagnostics --out PATH` does the same from a shell, spelled the way
|
||||
`config export-key --out` already spells it. The file is always the plain block, even
|
||||
under `--json` — it exists to be read by a person — and it is laid out at full width, so
|
||||
nothing is folded to whatever the terminal happened to be.
|
||||
`config export-key --out` already spells it.
|
||||
|
||||
### Changed
|
||||
|
||||
- `--json` and the plain face grew a shared projection for a set of facts
|
||||
(`ui/renderers.facts_pairs`), so the Diagnostics page cannot drift from what
|
||||
`meshterm diagnostics` prints: a field added to the report reaches both without anyone
|
||||
remembering to. A path also gained the lane constructor it never had
|
||||
(`ui/fields.path`) — the machine face had always spelled one correctly and the plain
|
||||
face could not render one at all.
|
||||
- **A report has a third face.** `ui/renderers.markdown_blocks` projects any report as
|
||||
markdown sections — the *document* face, where the other two are a record stream and a
|
||||
machine document. It is what the seam was built for: no tool was touched and no report
|
||||
changed. A block may now carry a `caption`, the heading a format that *has* headings
|
||||
draws above it; plain and JSON ignore it, and markdown makes it a `##`.
|
||||
|
||||
Rows are a list rather than a table, and that was measured: a markdown table's cells are
|
||||
cropped with an ellipsis at a narrow width, and a diagnostics page whose one long value is
|
||||
cut is missing the half that mattered.
|
||||
|
||||
- A path gained the lane constructor it never had (`ui/fields.path`) — the machine face had
|
||||
always spelled one correctly and the plain face could not render one at all.
|
||||
|
||||
## [0.9.0] — 2026-09-22
|
||||
|
||||
|
||||
+34
-22
@@ -1489,10 +1489,11 @@ $ meshterm diagnostics
|
||||
meshterm 0.3.7
|
||||
install frozen
|
||||
|
||||
os Windows 11
|
||||
os_build 10.0.26100
|
||||
arch AMD64
|
||||
python 3.13.1
|
||||
os Windows 11
|
||||
os_build 10.0.26100
|
||||
arch AMD64
|
||||
python 3.13.1
|
||||
|
||||
terminal Windows Terminal
|
||||
term -
|
||||
colorterm truecolor
|
||||
@@ -1525,17 +1526,21 @@ runs_failed 3
|
||||
first_heard 8mo
|
||||
last_heard now
|
||||
|
||||
TABLE ROWS
|
||||
observations 418203
|
||||
runs 912
|
||||
traces 47
|
||||
...
|
||||
TABLE ROWS
|
||||
observations 418203
|
||||
runs 912
|
||||
traces 47
|
||||
|
||||
PREFERENCE VALUE
|
||||
log_level DEBUG
|
||||
map_style terrain
|
||||
```
|
||||
|
||||
The seven groups are the page's seven sections — build, host, terminal, radio, storage,
|
||||
stored rows, changed preferences. The command line shows them as blank-line-separated
|
||||
records, because that face is a stream to grep; the page and the saved file show them as
|
||||
markdown headings, because that one is a document to read.
|
||||
|
||||
**The radio is asked but never required.** A report about a companion that will not connect
|
||||
is exactly the report most worth filing, so a failure to reach it becomes `connected no`
|
||||
with the radio's own words in `error`, and every other fact still arrives.
|
||||
@@ -1553,7 +1558,7 @@ feature and not a habit: the block is designed to be pasted in public by somebod
|
||||
not read it, and `tests/test_diagnostics.py` plants real secrets in every store that holds
|
||||
one and fails if any of them — or a field merely *named* like one — reaches either face.
|
||||
|
||||
Under `--json`, the three fact blocks merge into one flat object and the two listings keep
|
||||
Under `--json`, the five fact blocks merge into one flat object and the two listings keep
|
||||
a key of their own:
|
||||
|
||||
```json
|
||||
@@ -1563,23 +1568,30 @@ a key of their own:
|
||||
**`--out PATH` writes it to a file instead of printing it**, and the answer becomes the
|
||||
path — the same trade `config export-key --out` makes, since a caller who asked for a file
|
||||
wants to be told where it is rather than handed the contents they just redirected into it.
|
||||
The file is always the plain block, even under `--json`: it exists to be attached to an
|
||||
issue and read by a person. Redirection still works and is not replaced; what `--out` adds
|
||||
is a file laid out at full width rather than folded to whatever the terminal happened to be.
|
||||
|
||||
```console
|
||||
$ meshterm diagnostics --out meshterm-diagnostics.txt
|
||||
$ meshterm diagnostics --out meshterm-diagnostics.md
|
||||
✓ diagnostics written — attach it to the report.
|
||||
/home/jp/meshterm-diagnostics.txt
|
||||
/home/jp/meshterm-diagnostics.md
|
||||
```
|
||||
|
||||
In the menu this is the **Diagnostics** page under *This app*, drawn on a bare frame — no
|
||||
border, no header, no footer, no key hints — because a terminal is selected by dragging and
|
||||
every one of those is a character the clipboard would carry into the issue. Its one line of
|
||||
its own is `s save this to a file`, which writes `meshterm-diagnostics.txt` into the config
|
||||
directory beside the log and then says so on that same line. A bare frame has nowhere else
|
||||
to advertise a key, and one trailing line after a blank row costs a paste far less than a
|
||||
border costs it on every line.
|
||||
**The file is markdown**, whichever face the run was printing, because an issue tracker
|
||||
renders markdown as written — no fence, no apology. Every value is a code span, so a
|
||||
Windows path's backslashes and a firmware error's asterisks arrive as themselves rather
|
||||
than as markdown. Redirection still works and is not replaced; `--out` is what gives you
|
||||
the document rather than the record stream.
|
||||
|
||||
In the menu this is the **Diagnostics** page under *This app*, and it is one of the app's
|
||||
**written pages** — the same markdown renderer behind the About pages, drawing the same
|
||||
document the file holds. That buys what a long block most needs: its `##` headings are
|
||||
landmarks, so each pins to the top row while its own section scrolls under it and
|
||||
`^PgUp`/`^PgDn` step section by section.
|
||||
|
||||
`s` saves, advertised in the footer hint on a desktop and on the lane's free **F3** slot on
|
||||
the PicoCalc — never in the page's body, which belongs to the report. It writes
|
||||
`meshterm-diagnostics.md` into the config directory beside the log, and answers in a popup
|
||||
naming the path. A write that fails says so in the same popup, in red, and leaves the page
|
||||
readable.
|
||||
|
||||
#### `meshterm platform`
|
||||
|
||||
|
||||
+112
-50
@@ -17,10 +17,11 @@ without naming anyone the reporter talks to. :func:`~meshterm.core.hostinfo.host
|
||||
own side, so it cannot be crossed here by accident.
|
||||
|
||||
**One report, three faces**, which is the whole reason this is a report and not a
|
||||
``print``: the menu draws it on a bare frame with nothing around it to select by mistake
|
||||
(see :mod:`meshterm.ui.diagnostics`), ``meshterm diagnostics`` prints the same rows for
|
||||
anyone who would rather redirect than copy, and ``--json`` hands the same facts to
|
||||
whatever files the issue.
|
||||
``print``: the menu draws it as a written page (see :mod:`meshterm.ui.diagnostics`) and
|
||||
saves it as markdown, ``meshterm diagnostics`` prints the same facts as aligned records for
|
||||
anyone who would rather redirect than read, and ``--json`` hands them to whatever files the
|
||||
issue. The document face is the one that leaves this machine, so it is the one written in
|
||||
the format an issue tracker already renders.
|
||||
|
||||
The device is asked but never required. A report about a radio that will not connect is
|
||||
exactly the report that most needs to be filed, so a failure to reach it becomes a fact in
|
||||
@@ -29,6 +30,7 @@ the block (``connected``/``error``) rather than an error that replaces it.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -44,13 +46,10 @@ if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
_ROLES = {1: "client", 2: "repeater", 3: "room server", 4: "sensor"}
|
||||
|
||||
#: What the saved file is called, in the config directory beside the log it sits next to
|
||||
#: in a bug report. Named for the app rather than just ``diagnostics.txt`` because the one
|
||||
#: in a bug report. Named for the app rather than just ``diagnostics.md`` because the one
|
||||
#: thing that happens to this file is being moved somewhere else and attached, and by then
|
||||
#: the directory it came from is no longer saying what it is.
|
||||
#:
|
||||
#: ``.txt`` and not ``.md``: the content is aligned columns, which markdown would reflow
|
||||
#: into a paragraph, and a plain-text extension is the one every issue tracker accepts.
|
||||
DIAGNOSTICS_FILENAME = "meshterm-diagnostics.txt"
|
||||
DIAGNOSTICS_FILENAME = "meshterm-diagnostics.md"
|
||||
|
||||
|
||||
@register
|
||||
@@ -88,12 +87,10 @@ class DiagnosticsTool(Tool):
|
||||
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
|
||||
"""Open the page in the menu; returning ``None`` completes with no run row.
|
||||
|
||||
The same shape the About pages use: the menu's whole interaction *is* the screen,
|
||||
and a run row recording that somebody looked at their own version number would be
|
||||
a line in the log for every glance. Saving from the page is likewise not a run:
|
||||
the page writes the file itself and says so on its own last line, because a
|
||||
confirmation dialog over a page whose entire point is being unobstructed would
|
||||
cover the thing it was confirming.
|
||||
The same shape the About pages use — and the same screen, since the page *is* one:
|
||||
the menu's whole interaction is the reading of it, and a run row recording that
|
||||
somebody looked at their own version number would be a line in the log for every
|
||||
glance. Saving from the page is likewise not a run.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
@@ -103,7 +100,8 @@ class DiagnosticsTool(Tool):
|
||||
"""
|
||||
from ..ui.diagnostics import open_diagnostics_page
|
||||
|
||||
await open_diagnostics_page(ctx, await build_report(ctx), save_path=default_path(ctx))
|
||||
report = await build_report(ctx)
|
||||
await open_diagnostics_page(ctx, markdown_source(report), save_path=default_path(ctx))
|
||||
return None
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
@@ -125,7 +123,7 @@ class DiagnosticsTool(Tool):
|
||||
if out is None:
|
||||
return ToolResult(summary={"tool": self.name}, report=report)
|
||||
|
||||
written = write_report(report, out)
|
||||
written = write_markdown(markdown_source(report), out)
|
||||
ctx.ui.ack("[ok]✓[/ok] diagnostics written — attach it to the report.")
|
||||
return ToolResult(
|
||||
summary={"tool": self.name, "path": str(written)},
|
||||
@@ -137,22 +135,26 @@ class DiagnosticsTool(Tool):
|
||||
async def build_report(ctx: AppContext) -> Report:
|
||||
"""Gather every diagnostic fact, as the report both faces render.
|
||||
|
||||
Five blocks in the order a reader needs them: what this program is, what it is running
|
||||
on, what it is drawing into, what radio it found, and how much history it has. The
|
||||
three :class:`~meshterm.ui.report.Facts` blocks merge into one flat JSON object and the
|
||||
two :class:`~meshterm.ui.report.Listing` blocks keep a key of their own, which is also
|
||||
why the table counts are a listing — a table named after a fact would otherwise be able
|
||||
to collide with it.
|
||||
Seven blocks in the order a reader needs them: what this program is, what it is running
|
||||
on, what it is drawing into, what radio it found, how much history it has, what that
|
||||
history is made of, and what has been changed from the defaults. Each carries a
|
||||
``caption``, which is what the markdown face turns into the page's ``##`` landmarks.
|
||||
|
||||
The five :class:`~meshterm.ui.report.Facts` blocks merge into one flat JSON object and
|
||||
the two :class:`~meshterm.ui.report.Listing` blocks keep a key of their own — which is
|
||||
also why the table counts are a listing, since a table named after a fact would
|
||||
otherwise be able to collide with it.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
|
||||
Returns:
|
||||
The five-block report.
|
||||
The seven-block report.
|
||||
"""
|
||||
return (
|
||||
_app_facts(),
|
||||
_host_facts(),
|
||||
_terminal_facts(),
|
||||
await _device_facts(ctx),
|
||||
_mesh_facts(ctx),
|
||||
_table_listing(ctx),
|
||||
@@ -160,6 +162,44 @@ async def build_report(ctx: AppContext) -> Report:
|
||||
)
|
||||
|
||||
|
||||
def markdown_source(report: Report, *, when: datetime | None = None) -> str:
|
||||
"""The whole block as a markdown document — the page's source, and the file's contents.
|
||||
|
||||
Markdown because both places this goes already speak it: the menu draws it through
|
||||
:func:`~meshterm.ui.markdown.render_markdown`, the same renderer behind the About
|
||||
pages, so the page gets real ``##`` landmarks that pin as it scrolls and that its
|
||||
section jumps step by; and an issue tracker renders it as written, with no fence and
|
||||
no apology. The command line keeps its aligned records — that face is a stream a
|
||||
person greps, and headings are furniture on it.
|
||||
|
||||
Args:
|
||||
report: The blocks to write out.
|
||||
when: The moment to stamp in the colophon (defaults to now, local). Injectable so
|
||||
the same report can be rendered twice and compared.
|
||||
|
||||
Returns:
|
||||
Markdown source, ready for :func:`~meshterm.ui.markdown.render_markdown` or a file.
|
||||
"""
|
||||
from .. import __version__
|
||||
from ..ui.renderers import markdown_blocks
|
||||
|
||||
stamp = (when or datetime.now().astimezone()).strftime("%Y-%m-%d %H:%M")
|
||||
return (
|
||||
# The `#` title carries what the frame around it cannot — which is the same reason
|
||||
# `about.md` has one and the other three written pages do not. The screen is already
|
||||
# headed "Diagnostics"; repeating that would spend the page's first line saying
|
||||
# nothing, while the version belongs at the top of a file somebody will open later.
|
||||
# Emphasis makes it the muted qualifier beside the name.
|
||||
f"# MeshTerm diagnostics *v{__version__}*\n\n"
|
||||
# The standfirst sits flush and muted under the title, and says the one thing a
|
||||
# reader needs before reading any of it: this is safe to hand over.
|
||||
"Everything a bug report opens with. Nothing here is private.\n\n"
|
||||
f"{markdown_blocks(report)}\n\n"
|
||||
"---\n\n"
|
||||
f"Taken {stamp}.\n"
|
||||
)
|
||||
|
||||
|
||||
def default_path(ctx: AppContext) -> Path:
|
||||
"""Where the page saves when nobody named a place: beside the log, in the config dir.
|
||||
|
||||
@@ -170,29 +210,27 @@ def default_path(ctx: AppContext) -> Path:
|
||||
return ctx.settings.config_dir / DIAGNOSTICS_FILENAME
|
||||
|
||||
|
||||
def write_report(report: Report, path: Path | str) -> Path:
|
||||
"""Write a report's plain rendering to ``path``, creating its directory.
|
||||
def write_markdown(source: str, path: Path | str) -> Path:
|
||||
"""Write a markdown document to ``path``, creating its directory.
|
||||
|
||||
**Always the plain face**, whatever the run was printing. A file exists here to be
|
||||
attached to an issue and read by a person, and a document of the same facts helps that
|
||||
person less than the block does; anyone who wants the machine face can redirect
|
||||
``--json`` and already has a better filename for it than this one.
|
||||
**Always markdown**, whatever face the run was printing. This file exists to be
|
||||
attached to an issue and read there, and an issue tracker renders markdown; anyone who
|
||||
wants the machine face can redirect ``--json`` and already has a better name for that
|
||||
file than this one.
|
||||
|
||||
Args:
|
||||
report: The block to write.
|
||||
source: The document, from :func:`markdown_source`.
|
||||
path: The destination.
|
||||
|
||||
Returns:
|
||||
The path written, resolved.
|
||||
The path written.
|
||||
|
||||
Raises:
|
||||
OSError: If the directory cannot be made or the file cannot be written.
|
||||
"""
|
||||
from ..ui.renderers import render_to_text
|
||||
|
||||
written = Path(path)
|
||||
written.parent.mkdir(parents=True, exist_ok=True)
|
||||
written.write_text(render_to_text(report), encoding="utf-8")
|
||||
written.write_text(source, encoding="utf-8")
|
||||
return written
|
||||
|
||||
|
||||
@@ -219,6 +257,7 @@ def _app_facts() -> Any:
|
||||
|
||||
return Facts(
|
||||
key="meshterm",
|
||||
caption="Build",
|
||||
fields=(
|
||||
fields.word("meshterm", "meshterm"),
|
||||
fields.word("install", "install"),
|
||||
@@ -228,12 +267,38 @@ def _app_facts() -> Any:
|
||||
|
||||
|
||||
def _host_facts() -> Any:
|
||||
"""The machine and the terminal, including every verdict resolved once at boot.
|
||||
"""The machine underneath: which OS, which build, which processor, which Python."""
|
||||
from ..core import hostinfo
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
The boot-time verdicts are the point of this block. Whether icons draw, whether the
|
||||
path widget's separators draw, which platform flavour resolved and *why* — each is
|
||||
decided from the environment before the first frame, none is visible on any screen,
|
||||
and each is the answer to a whole family of "it looks wrong" reports.
|
||||
machine = hostinfo.host()
|
||||
return Facts(
|
||||
key="host",
|
||||
caption="Host",
|
||||
fields=(
|
||||
fields.word("os", "os"),
|
||||
fields.word("os_build", "os_build"),
|
||||
fields.word("arch", "arch"),
|
||||
fields.word("python", "python"),
|
||||
),
|
||||
values={
|
||||
"os": machine.os,
|
||||
"os_build": machine.os_build,
|
||||
"arch": machine.arch,
|
||||
"python": machine.python,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _terminal_facts() -> Any:
|
||||
"""The terminal, and every verdict about it resolved once at boot.
|
||||
|
||||
Its own section rather than more host facts, because these are what a "it looks wrong"
|
||||
report is actually about, and a reader skimming for them should not have to pass the
|
||||
processor architecture on the way. Whether icons draw, whether the path widget's
|
||||
separators draw, which platform flavour resolved and *why* — each is decided from the
|
||||
environment before the first frame and none of it is visible on any screen.
|
||||
"""
|
||||
from ..core import hostinfo
|
||||
from ..platforms import get_platform
|
||||
@@ -241,17 +306,14 @@ def _host_facts() -> Any:
|
||||
from ..ui.report import Facts
|
||||
from ..ui.termfont import detect_terminal_font, emoji_support, powerline_support
|
||||
|
||||
machine, term = hostinfo.host(), hostinfo.terminal()
|
||||
term = hostinfo.terminal()
|
||||
emoji = emoji_support()
|
||||
powerline = powerline_support()
|
||||
font = detect_terminal_font()
|
||||
return Facts(
|
||||
key="host",
|
||||
key="terminal",
|
||||
caption="Terminal",
|
||||
fields=(
|
||||
fields.word("os", "os"),
|
||||
fields.word("os_build", "os_build"),
|
||||
fields.word("arch", "arch"),
|
||||
fields.word("python", "python"),
|
||||
fields.word("terminal", "terminal"),
|
||||
fields.word("term", "term"),
|
||||
fields.word("colorterm", "colorterm"),
|
||||
@@ -264,10 +326,6 @@ def _host_facts() -> Any:
|
||||
fields.word("powerline", "powerline"),
|
||||
),
|
||||
values={
|
||||
"os": machine.os,
|
||||
"os_build": machine.os_build,
|
||||
"arch": machine.arch,
|
||||
"python": machine.python,
|
||||
"terminal": term.program,
|
||||
"term": term.term,
|
||||
"colorterm": term.colorterm,
|
||||
@@ -340,6 +398,7 @@ async def _device_facts(ctx: AppContext) -> Any:
|
||||
|
||||
return Facts(
|
||||
key="device",
|
||||
caption="Radio",
|
||||
fields=(
|
||||
fields.flag("connected", "connected"),
|
||||
fields.word("transport", "transport"),
|
||||
@@ -375,6 +434,7 @@ def _mesh_facts(ctx: AppContext) -> Any:
|
||||
first, last = ctx.repo.observation_span()
|
||||
return Facts(
|
||||
key="mesh",
|
||||
caption="Storage",
|
||||
fields=(
|
||||
fields.path("config_dir", "config_dir"),
|
||||
fields.integer("db_size_kb", "db_size_kb"),
|
||||
@@ -412,6 +472,7 @@ def _table_listing(ctx: AppContext) -> Any:
|
||||
counts = ctx.repo.table_counts()
|
||||
return Listing(
|
||||
key="tables",
|
||||
caption="Stored rows",
|
||||
columns=(fields.word("table", "TABLE"), Column(key="rows", lanes=(_rows_lane(),))),
|
||||
rows=[{"table": name, "rows": n} for name, n in counts.items()],
|
||||
)
|
||||
@@ -431,6 +492,7 @@ def _preference_listing(ctx: AppContext) -> Any:
|
||||
preferences = ctx.preferences or current()
|
||||
return Listing(
|
||||
key="preferences",
|
||||
caption="Changed preferences",
|
||||
columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")),
|
||||
rows=[
|
||||
{"preference": key, "value": str(value)}
|
||||
|
||||
+107
-166
@@ -1,29 +1,24 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The Diagnostics page: the bug-report block, on a frame with nothing else on it.
|
||||
"""The Diagnostics page: the bug-report block, drawn as one of the app's written pages.
|
||||
|
||||
**A bare frame, and the reason is mechanical rather than aesthetic.** The one thing this
|
||||
page is for is getting its text out of the terminal and into an issue, and a terminal is
|
||||
selected by dragging: a panel border is characters sitting on the *same lines* as the
|
||||
content, so every line of a line-wise selection arrives with a box-drawing glyph welded to
|
||||
each end. Same for a header, a footer hint and an F-key lane, which arrive as text that
|
||||
looks like part of the report to whoever reads the paste. So this page draws its rows on a
|
||||
:attr:`~meshterm.ui.tui.screen.Screen.bare` frame, the one the share screen uses for the
|
||||
same kind of reason — there, a camera wants no contrast arguing with the code; here, a
|
||||
clipboard wants no furniture pretending to be data. Esc leaves, as everywhere.
|
||||
It is prose-shaped rather than screen-shaped — a title, a standfirst, sections, and a list
|
||||
under each — so it is markdown, and it goes through the renderer the About pages already
|
||||
use (:mod:`meshterm.ui.markdown`). That is not a presentational choice made twice: the same
|
||||
document is what gets **saved**, and an issue tracker renders markdown as written. One
|
||||
source, one look, on screen and in the issue.
|
||||
|
||||
The page's **one** line of its own is the save hint, and that is a trade rather than an
|
||||
exception: a bare frame has nowhere to advertise a key, so the alternative was a page that
|
||||
could write the file and never said so. One trailing line after a blank row costs a paste
|
||||
far less than a border costs it on every line, and a drag that follows the block stops
|
||||
above it anyway (see :class:`DiagnosticsPage`).
|
||||
Being a written page buys the two things a long block most needs and neither of which a
|
||||
hand-built screen had: its ``##`` headings are landmarks, so each pins to the top row while
|
||||
its own section scrolls under it and ``^PgUp``/``^PgDn`` step section by section; and the
|
||||
PicoCalc's F-key lane picks up ``Sect ↑``/``Sect ↓`` for the same reason a grouped list
|
||||
does. :class:`~meshterm.ui.about.AboutPage` already does all of it, so this is that page
|
||||
with one verb added.
|
||||
|
||||
The rows come from the tool's :mod:`~meshterm.ui.report`, projected through the very lanes
|
||||
the command line prints (:func:`~meshterm.ui.renderers.facts_pairs`), so what is copied off
|
||||
this screen and what ``meshterm diagnostics`` writes to a pipe are the same facts in the
|
||||
same words. Only the *layout* differs, and in one respect: the scripted face crops a value
|
||||
too wide for its lane, which is right for a record somebody will split on whitespace and
|
||||
wrong for a page whose entire job is to be complete — so here a long value hangs under its
|
||||
own block instead (the app-wide rule for a wrapped labelled row).
|
||||
The verb is **save** (:data:`SAVE_KEY`), advertised where the platform advertises verbs —
|
||||
in the footer hint on a desktop, on the free F3 slot of the lane on the console — and never
|
||||
in the page's own body, which belongs to the report. It answers in a popup, because the
|
||||
answer is a path the reader may need to read carefully and because a write can fail; the
|
||||
page underneath is unchanged and still there when the popup goes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,100 +26,122 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from .tui.screen import ScrollScreen
|
||||
from .about import AboutPage
|
||||
from .markdown import render_markdown
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..context import AppContext
|
||||
from .report import Facts, Listing, Report
|
||||
from .tui.session import TuiSession
|
||||
|
||||
|
||||
#: The key that writes the block to a file, and the only affordance this page advertises.
|
||||
#: The key that writes the page to a file. A bare letter, which a read-only page can
|
||||
#: afford because it has no filter and no field for one to mean anything else in.
|
||||
SAVE_KEY = "s"
|
||||
|
||||
#: The action both the key and the lane's chip dispatch, so the chip is not a second
|
||||
#: implementation of the key.
|
||||
SAVE_ACTION = "save"
|
||||
|
||||
class DiagnosticsPage(ScrollScreen):
|
||||
"""The bug-report block, full-screen and unadorned, with one thing it can do.
|
||||
|
||||
Bare, so a mouse drag takes the facts and only the facts; scrollable, because the
|
||||
block outgrows a 26-row console and a truncated bug report is worse than a scrolled
|
||||
one.
|
||||
class DiagnosticsPage(AboutPage):
|
||||
"""The written diagnostics page, plus the one verb that writes it to a file.
|
||||
|
||||
**The save line is the one concession, and it is a deliberate one.** A bare frame has
|
||||
no footer and no F-key lane, so a page that could save and never said so would be a
|
||||
key nobody could find — on the PicoCalc especially, where the lane is normally the only
|
||||
place an affordance is advertised at all. So the page carries its own last line. It
|
||||
costs a paste one trailing line where a border would have cost it a character on
|
||||
*every* line, and a blank row above it means a drag that follows the block stops
|
||||
before it of its own accord.
|
||||
|
||||
Saving answers **on that same line** rather than in a dialog: the reader pressed a key
|
||||
to be told where a file went, and a box over the page would cover the thing they were
|
||||
about to copy. It is also what lets a failure — a read-only home, a full disk — be said
|
||||
without inventing an error screen for it.
|
||||
Everything about how it *reads* is :class:`~meshterm.ui.about.AboutPage`: the pager,
|
||||
the sticky ``##`` headings, the section jumps, the lane that dims when there is nothing
|
||||
to page. What is added is the save, on the one slot the About pages leave free.
|
||||
"""
|
||||
|
||||
bare = True
|
||||
|
||||
def __init__(self, report: Report, *, save_path: Path) -> None:
|
||||
"""Lay the report out as the page's body.
|
||||
def __init__(self, source: str, *, session: TuiSession, save_path: Path) -> None:
|
||||
"""Show ``source`` as the page, saving to ``save_path`` on demand.
|
||||
|
||||
Args:
|
||||
report: The tool's report — the same blocks the CLI renders.
|
||||
save_path: Where :data:`SAVE_KEY` writes the block.
|
||||
source: The markdown document, from
|
||||
:func:`~meshterm.tools.diagnostics.markdown_source`.
|
||||
session: The session, for the popup the save answers in.
|
||||
save_path: Where :data:`SAVE_KEY` writes.
|
||||
"""
|
||||
super().__init__(Text(""), title="Diagnostics", floating=False)
|
||||
self._report = report
|
||||
super().__init__("Diagnostics", render_markdown(source))
|
||||
self._source = source
|
||||
self._session = session
|
||||
self._save_path = save_path
|
||||
self._saved: Path | None = None
|
||||
self._error: str | None = None
|
||||
self._refresh()
|
||||
self._saving = False
|
||||
|
||||
@property
|
||||
def footer_hint(self) -> str: # type: ignore[override]
|
||||
"""The read-only page's keys, with the save between the pager and Esc.
|
||||
|
||||
Navigation first, then the action, then Esc — the standard sentence shape. The
|
||||
pager atom comes and goes with :attr:`~Screen.content_overflows`, as it does on
|
||||
every read-only page, so the line never names a key that would do nothing.
|
||||
"""
|
||||
scroll = "↑↓ PgUp/PgDn scroll · " if self.content_overflows else ""
|
||||
return f"{scroll}{SAVE_KEY} save · Esc back"
|
||||
|
||||
@property
|
||||
def fkey_lane(self):
|
||||
"""The About page's lane, with ``Save`` on the slot it leaves free.
|
||||
|
||||
F1/F2 are the section pair and F4/F5 the pager, so F3 is the one free primary slot
|
||||
— and on this platform the lane is the *only* place a bare letter can advertise
|
||||
itself at all, which is what makes the chip load-bearing rather than decorative.
|
||||
"""
|
||||
from .tui.fkeys import FPair
|
||||
|
||||
lane = list(super().fkey_lane)
|
||||
lane[2] = FPair("Save", SAVE_ACTION)
|
||||
return lane
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""Save on :data:`SAVE_KEY`; scroll or leave on everything else.
|
||||
|
||||
The key arrives as bare text, which a read-only page can afford precisely because
|
||||
it has no filter and no field for a letter to mean anything else in.
|
||||
"""
|
||||
if action == "text" and data.lower() == SAVE_KEY:
|
||||
self._save()
|
||||
"""Save on the key or the chip; scroll, jump or leave on everything else."""
|
||||
if action == SAVE_ACTION or (action == "text" and data.lower() == SAVE_KEY):
|
||||
self._begin_save()
|
||||
return
|
||||
super().handle(action, data)
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Write the block, and say on the page's own last line what happened."""
|
||||
from ..tools.diagnostics import write_report
|
||||
def _begin_save(self) -> None:
|
||||
"""Write the file and report it in a popup, off the key handler.
|
||||
|
||||
``run_detached`` because a screen's ``handle`` is synchronous and the popup is a
|
||||
screen: the flow can only be *started* here, and the task it starts sits outside
|
||||
the navigation chain (see :meth:`~meshterm.ui.tui.session.TuiSession.run_detached`).
|
||||
One save at a time, so a held key cannot stack popups over each other.
|
||||
"""
|
||||
if self._saving:
|
||||
return
|
||||
self._saving = True
|
||||
|
||||
async def run() -> None:
|
||||
try:
|
||||
await self._session.message_dialog(self._write(), title="Diagnostics")
|
||||
finally:
|
||||
self._saving = False
|
||||
|
||||
self._session.run_detached(run())
|
||||
|
||||
def _write(self) -> str:
|
||||
"""Write the document, and say what happened in one marked-up line.
|
||||
|
||||
The message carries its own mark — ``✓`` or ``✗`` — which is also what tells the
|
||||
popup which border to take, so a failed write arrives red without anything here
|
||||
naming a colour.
|
||||
"""
|
||||
from ..tools.diagnostics import write_markdown
|
||||
|
||||
try:
|
||||
self._saved, self._error = write_report(self._report, self._save_path), None
|
||||
written = write_markdown(self._source, self._save_path)
|
||||
except OSError as exc:
|
||||
# A home that is read-only, full, or not there. The reader can still copy the
|
||||
# block off the screen, which is the whole reason that path exists as well.
|
||||
self._saved, self._error = None, exc.strerror or str(exc)
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self) -> None:
|
||||
"""Rebuild the body: the block, a blank row, then the page's one line of its own."""
|
||||
self.replace_content(Group(render_report(self._report), Text(""), self._status()))
|
||||
|
||||
def _status(self) -> Text:
|
||||
"""The last line: what the key does, or what it did."""
|
||||
if self._error is not None:
|
||||
return Text(f"could not save to {self._save_path}: {self._error}", style="err")
|
||||
if self._saved is not None:
|
||||
return Text(f"saved to {self._saved}", style="ok")
|
||||
return Text(f"{SAVE_KEY} save this to a file", style="muted")
|
||||
# A read-only home, a full disk, a path that is not there. The page itself is
|
||||
# untouched and still readable, which is the fallback that matters.
|
||||
reason = exc.strerror or str(exc)
|
||||
return f"[err]✗[/err] could not save to {self._save_path}\n{reason}"
|
||||
return f"[ok]✓[/ok] saved to\n{written}"
|
||||
|
||||
|
||||
async def open_diagnostics_page(ctx: AppContext, report: Report, *, save_path: Path) -> None:
|
||||
async def open_diagnostics_page(ctx: AppContext, source: str, *, save_path: Path) -> None:
|
||||
"""Open the Diagnostics page full-screen and hold it until the reader backs out.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context (must be running the interactive TUI).
|
||||
report: The block to show.
|
||||
source: The markdown document to show and to save.
|
||||
save_path: Where the page's save key writes.
|
||||
|
||||
Raises:
|
||||
@@ -134,84 +151,8 @@ async def open_diagnostics_page(ctx: AppContext, report: Report, *, save_path: P
|
||||
|
||||
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
|
||||
raise RuntimeError("the Diagnostics page is only available in the menu")
|
||||
await ctx.ui.session.run_screen(DiagnosticsPage(report, save_path=save_path))
|
||||
session = ctx.ui.session
|
||||
await session.run_screen(DiagnosticsPage(source, session=session, save_path=save_path))
|
||||
|
||||
|
||||
def render_report(report: Report) -> RenderableType:
|
||||
"""Lay a report out as the page's body: each block in order, a blank row between.
|
||||
|
||||
A block that would draw nothing — a listing with no rows — is left out along with its
|
||||
separator, exactly as the scripted face leaves it out, so the two never disagree about
|
||||
whether a section exists.
|
||||
|
||||
Args:
|
||||
report: The blocks to draw.
|
||||
|
||||
Returns:
|
||||
The page body.
|
||||
"""
|
||||
from .report import Facts
|
||||
|
||||
parts: list[RenderableType] = []
|
||||
for block in report:
|
||||
drawn = _facts(block) if isinstance(block, Facts) else _listing(block)
|
||||
if drawn is None:
|
||||
continue
|
||||
if parts:
|
||||
parts.append(Text(""))
|
||||
parts.append(drawn)
|
||||
return Group(*parts)
|
||||
|
||||
|
||||
def _facts(block: Facts) -> RenderableType | None:
|
||||
"""One facts block: key on the left, value on the right, wrapping under itself."""
|
||||
from .renderers import facts_pairs
|
||||
|
||||
rows = facts_pairs(block)
|
||||
if not rows:
|
||||
return None
|
||||
table = _grid()
|
||||
table.add_column(no_wrap=True)
|
||||
# The one column allowed to wrap, and the reason this page is not just the scripted
|
||||
# renderer at a different width: a config directory or a connection error cropped to
|
||||
# the frame is a bug report missing the half that mattered.
|
||||
table.add_column(overflow="fold")
|
||||
for key, value in rows:
|
||||
table.add_row(Text(key, style="muted"), Text(value))
|
||||
return table
|
||||
|
||||
|
||||
def _listing(block: Listing) -> RenderableType | None:
|
||||
"""One listing: its header row, then its records. Nothing where there are no records."""
|
||||
if not block.rows:
|
||||
return None
|
||||
lanes = block.lanes()
|
||||
table = _grid()
|
||||
for _column, lane in lanes:
|
||||
table.add_column(justify=lane.align, no_wrap=True)
|
||||
table.add_row(*(Text(lane.header, style="muted") for _column, lane in lanes))
|
||||
for row in block.rows:
|
||||
table.add_row(*(Text(lane.render(row.get(column.key))) for column, lane in lanes))
|
||||
return table
|
||||
|
||||
|
||||
def _grid() -> Table:
|
||||
"""A frameless table that starts in column zero.
|
||||
|
||||
No box, no edge padding and no leading pad: every cell of chrome on a row is a
|
||||
character the clipboard carries, and an indent before the first key is one the reader
|
||||
of the issue has to strip off every line.
|
||||
"""
|
||||
from .script import GUTTER
|
||||
|
||||
return Table(
|
||||
box=None,
|
||||
show_header=False,
|
||||
show_edge=False,
|
||||
pad_edge=False,
|
||||
expand=False,
|
||||
padding=(0, GUTTER, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DiagnosticsPage", "open_diagnostics_page", "render_report"]
|
||||
__all__ = ["SAVE_ACTION", "SAVE_KEY", "DiagnosticsPage", "open_diagnostics_page"]
|
||||
+58
-30
@@ -33,6 +33,7 @@ compartment inside it.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from enum import Enum
|
||||
@@ -61,44 +62,71 @@ class OutputFormat(str, Enum):
|
||||
JSON = "json"
|
||||
|
||||
|
||||
#: How wide a report is drawn when it is going into a *file* rather than onto a terminal.
|
||||
#: Wide enough that no lane ever crops — a saved report exists to be complete, and the
|
||||
#: table sizes each column to its content, so nothing is padded out to this either.
|
||||
FILE_WIDTH = 200
|
||||
def markdown_blocks(report: Report | None) -> str:
|
||||
"""Project a report as markdown sections — the third face, and the only *document* one.
|
||||
|
||||
The plain face is a stream of records and the machine face is a document for a
|
||||
program; this one is a page for a person to read, on a screen through
|
||||
:func:`~meshterm.ui.markdown.render_markdown` and in an issue tracker through nothing
|
||||
at all. It is where :attr:`~meshterm.ui.report.Block.caption` finally means something:
|
||||
markdown has headings, and a heading is what a reader navigates by.
|
||||
|
||||
def render_to_text(report: Report | None, fmt: OutputFormat = OutputFormat.PLAIN) -> str:
|
||||
"""Render a report to a string, for writing somewhere that is not a terminal.
|
||||
Every value is written as **inline code**, and that is correctness rather than taste. A
|
||||
Windows path is full of backslashes, which markdown reads as escapes, and an error
|
||||
message a stranger's firmware wrote can hold an asterisk or a bracket. Inside a code
|
||||
span none of it is interpreted, so the value that arrives is the value that left — the
|
||||
same reason :func:`~meshterm.ui.script.name` quotes what it cannot vouch for. A value
|
||||
already holding a backtick gets a longer fence rather than a broken one.
|
||||
|
||||
The same renderers, on a capture console: a file gets exactly what the corresponding
|
||||
face would have printed, so there is no third rendering to keep in step with the other
|
||||
two. No colour, no highlighting and no markup interpretation — a node's name can
|
||||
contain a bracket, and a file is read by things that do not speak SGR.
|
||||
Rows are a **list**, not a table, and that was measured rather than assumed: a markdown
|
||||
table's cells are cropped with an ellipsis when the frame is narrow, and a diagnostics
|
||||
page whose one long value is cut is missing the half that mattered. A list item wraps
|
||||
and hangs under its own text at any width.
|
||||
|
||||
Args:
|
||||
report: The report to render, or ``None`` for nothing at all.
|
||||
fmt: Which face to write.
|
||||
report: The blocks to project, or ``None`` for nothing at all.
|
||||
|
||||
Returns:
|
||||
The rendered text, newline-terminated where it has any content.
|
||||
Markdown source: one ``##`` section per captioned block, blank-line separated.
|
||||
"""
|
||||
console = Console(
|
||||
width=FILE_WIDTH,
|
||||
color_system=None,
|
||||
highlight=False,
|
||||
markup=False,
|
||||
emoji=False,
|
||||
# A capture must not consult the real terminal for its width or its legs.
|
||||
force_terminal=False,
|
||||
)
|
||||
with console.capture() as captured:
|
||||
for_format(fmt, console).render(report)
|
||||
# Rich pads every cell out to its column's width, which is invisible on a terminal and
|
||||
# is trailing whitespace on disk — noise in a diff, and something a reader pasting the
|
||||
# file onward has to strip. The written pages already do the same (see
|
||||
# ``tools.about._rendered_page``).
|
||||
text = "\n".join(line.rstrip() for line in captured.get().splitlines())
|
||||
return f"{text}\n" if text.strip() else ""
|
||||
sections: list[str] = []
|
||||
for block in report or ():
|
||||
rows = _markdown_rows(block)
|
||||
head = f"## {block.caption}\n\n" if block.caption else ""
|
||||
# A section with nothing in it still says so. An absence is a fact here — "no
|
||||
# preferences changed" is something a maintainer can act on, where a section
|
||||
# silently missing only raises the question of whether it was ever built.
|
||||
sections.append(head + ("\n".join(rows) if rows else "*none*"))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def _markdown_rows(block: Block) -> list[str]:
|
||||
"""One markdown list item per row of a block."""
|
||||
if isinstance(block, Facts):
|
||||
return [f"- **{key}** — {_code(value)}" for key, value in facts_pairs(block)]
|
||||
lanes = block.lanes()
|
||||
if not lanes:
|
||||
return []
|
||||
items: list[str] = []
|
||||
for row in block.rows:
|
||||
# The first lane names the record and the rest describe it, which is what a
|
||||
# two-lane listing (a table and its count, a preference and its value) wants and
|
||||
# what a wider one degrades to gracefully.
|
||||
cells = [lane.render(row.get(column.key)) for column, lane in lanes]
|
||||
described = " · ".join(_code(cell) for cell in cells[1:])
|
||||
items.append(f"- **{cells[0]}** — {described}" if described else f"- **{cells[0]}**")
|
||||
return items
|
||||
|
||||
|
||||
def _code(value: str) -> str:
|
||||
"""One value as a markdown code span, fenced long enough to survive its own backticks."""
|
||||
if not value:
|
||||
return ""
|
||||
longest = max((len(run) for run in re.findall(r"`+", value)), default=0)
|
||||
fence = "`" * (longest + 1)
|
||||
# A span that starts or ends with a backtick needs a space the parser then eats.
|
||||
pad = " " if value.startswith("`") or value.endswith("`") else ""
|
||||
return f"{fence}{pad}{value}{pad}{fence}"
|
||||
|
||||
|
||||
def facts_pairs(block: Facts) -> list[tuple[str, str]]:
|
||||
|
||||
@@ -155,6 +155,13 @@ class Block:
|
||||
|
||||
Attributes:
|
||||
key: The name this block goes under in a multi-block document.
|
||||
caption: The heading a format that *has* headings draws above this block. The
|
||||
plain face has none — a heading over a key/value block is furniture on a
|
||||
line-oriented face, which is why ``config show`` drops even its column
|
||||
headers — and the machine face has ``key`` instead. The markdown projection
|
||||
makes it a ``##`` section, which is what gives a written page the landmarks
|
||||
its section jumps and its sticky headings step by. Empty means the block is
|
||||
drawn without one.
|
||||
plain_only: Whether this block is a rendering for a person that the machine face
|
||||
has no use for. The one case is a stream's closing summary: ``monitor`` prints
|
||||
a per-node aggregate under its live capture, and a consumer holding every
|
||||
@@ -164,6 +171,7 @@ class Block:
|
||||
"""
|
||||
|
||||
key: str
|
||||
caption: str = ""
|
||||
plain_only: bool = False
|
||||
|
||||
|
||||
|
||||
+168
-79
@@ -27,8 +27,16 @@ from meshterm.core.device_store import DeviceStore
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.core.preferences import Preferences
|
||||
from meshterm.persistence.repository import Repository
|
||||
from meshterm.tools.diagnostics import build_report
|
||||
from meshterm.ui.renderers import JsonRenderer, PlainRenderer, facts_pairs
|
||||
from meshterm.tools.diagnostics import build_report, markdown_source
|
||||
from meshterm.ui import fields
|
||||
from meshterm.ui.about import AboutPage
|
||||
from meshterm.ui.markdown import render_markdown
|
||||
from meshterm.ui.renderers import (
|
||||
JsonRenderer,
|
||||
PlainRenderer,
|
||||
facts_pairs,
|
||||
markdown_blocks,
|
||||
)
|
||||
from meshterm.ui.report import Facts, Listing
|
||||
|
||||
#: Planted into the stores that really hold this kind of value. Each is the kind of thing
|
||||
@@ -197,115 +205,163 @@ def test_the_page_and_the_command_line_state_the_same_facts(ctx: AppContext) ->
|
||||
assert value in printed
|
||||
|
||||
|
||||
def test_the_page_draws_no_chrome(ctx: AppContext) -> None:
|
||||
"""The page is bare, and its rows start in column zero.
|
||||
def test_the_page_is_a_written_page_with_landmarks(ctx: AppContext) -> None:
|
||||
"""The page is markdown through the About-page renderer, and its sections are landmarks.
|
||||
|
||||
Both are the same requirement seen twice: a terminal is selected by dragging, so a
|
||||
border, a header or a leading indent is a character the clipboard carries into the
|
||||
issue.
|
||||
The sections are the point of the format: a heading pins to the top row while its own
|
||||
rows scroll under it, the section jumps step by them, and the PicoCalc lane earns its
|
||||
``Sect up``/``Sect down`` pair for the same reason a grouped list does.
|
||||
"""
|
||||
page = _page(ctx)
|
||||
assert page.bare is True
|
||||
lines = [_strip(line) for line in page.render_body(72)]
|
||||
content = [line for line in lines if line.strip()]
|
||||
assert content, "the page should have drawn something"
|
||||
assert not content[0].startswith(" ")
|
||||
assert isinstance(page, AboutPage)
|
||||
assert page.bare is False
|
||||
assert {"Build", "Host", "Terminal", "Radio", "Storage"} <= set(page._doc.sections)
|
||||
|
||||
|
||||
def test_the_save_is_advertised_on_both_platforms(ctx: AppContext) -> None:
|
||||
"""The verb is named in the footer hint and on the lane's one free slot.
|
||||
|
||||
Never in the page's body, which belongs to the report — and never nowhere, which is
|
||||
what a bare frame would have forced. F1/F2 are the section pair and F4/F5 the pager,
|
||||
so F3 is the slot the About pages leave free.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import SAVE_ACTION, SAVE_KEY
|
||||
|
||||
page = _page(ctx)
|
||||
assert f"{SAVE_KEY} save" in page.footer_hint
|
||||
assert page.footer_hint.endswith("Esc back")
|
||||
assert page.fkey_lane[2] is not None
|
||||
assert page.fkey_lane[2].label == "Save"
|
||||
assert page.fkey_lane[2].action == SAVE_ACTION
|
||||
|
||||
|
||||
# --- saving it ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_saved_file_is_what_the_command_line_prints(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""The file holds the plain block, drawn wide enough that no lane is cropped.
|
||||
def test_the_saved_file_is_the_document_the_page_shows(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""The file holds the very markdown the page was built from — one source, two places."""
|
||||
from meshterm.tools.diagnostics import markdown_source, write_markdown
|
||||
|
||||
Always the plain face, whatever the run was printing: this file exists to be attached
|
||||
to an issue and read by a person.
|
||||
"""
|
||||
from meshterm.tools.diagnostics import write_report
|
||||
|
||||
report = _report(ctx)
|
||||
written = write_report(report, tmp_path / "out.txt")
|
||||
saved = written.read_text(encoding="utf-8")
|
||||
assert saved.strip()
|
||||
for block in report:
|
||||
if isinstance(block, Facts):
|
||||
for key, value in facts_pairs(block):
|
||||
assert key in saved
|
||||
assert value in saved
|
||||
source = markdown_source(_report(ctx))
|
||||
written = write_markdown(source, tmp_path / "out.md")
|
||||
assert written.read_text(encoding="utf-8") == source
|
||||
assert source.startswith("# MeshTerm diagnostics")
|
||||
|
||||
|
||||
def test_saving_creates_the_directory_it_needs(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""A destination whose parent does not exist yet is made rather than refused."""
|
||||
from meshterm.tools.diagnostics import write_report
|
||||
from meshterm.tools.diagnostics import markdown_source, write_markdown
|
||||
|
||||
written = write_report(_report(ctx), tmp_path / "nested" / "deeper" / "out.txt")
|
||||
written = write_markdown(markdown_source(_report(ctx)), tmp_path / "a" / "b" / "out.md")
|
||||
assert written.is_file()
|
||||
|
||||
|
||||
def test_the_page_advertises_the_save_key_then_reports_where_it_went(
|
||||
def test_the_key_and_the_chip_both_save_and_answer_in_a_popup(
|
||||
ctx: AppContext, tmp_path: Path
|
||||
) -> None:
|
||||
"""The page names its one key, and answers on the same line once it is pressed.
|
||||
"""Either affordance writes the file and reports the path in an acknowledgement popup.
|
||||
|
||||
A bare frame has no footer to advertise a key from, so the page carries the hint
|
||||
itself — and the confirmation replaces it rather than opening a dialog over the block
|
||||
the reader is about to copy.
|
||||
The chip dispatches the same action the key does, so it is not a second implementation
|
||||
of the same verb.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import SAVE_ACTION, SAVE_KEY
|
||||
|
||||
for trigger, target in ((("text", SAVE_KEY), "key.md"), ((SAVE_ACTION, ""), "chip.md")):
|
||||
path = tmp_path / target
|
||||
session = _Session()
|
||||
page = _page(ctx, session=session, save_path=path)
|
||||
page.handle(*trigger)
|
||||
assert path.is_file()
|
||||
assert len(session.messages) == 1
|
||||
assert "\u2713" in session.messages[0]
|
||||
assert str(path) in session.messages[0]
|
||||
|
||||
|
||||
def test_a_save_that_fails_is_reported_not_raised(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""An unwritable destination reports itself and leaves the page readable.
|
||||
|
||||
The page is still there to be read and copied off, which is the fallback that matters,
|
||||
so a failed write must never take it down. The mark is also what turns the popup red.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import SAVE_KEY
|
||||
|
||||
target = tmp_path / "saved" / "meshterm-diagnostics.txt"
|
||||
page = _page(ctx, save_path=target)
|
||||
assert "save this to a file" in _body(page)
|
||||
|
||||
page.handle("text", SAVE_KEY)
|
||||
assert target.is_file()
|
||||
# Matched in two parts because a tmp_path is long enough to wrap the status line,
|
||||
# which is the line behaving correctly rather than a failure.
|
||||
body = _body(page)
|
||||
assert "saved to" in body
|
||||
assert target.name in body
|
||||
assert "save this to a file" not in body
|
||||
|
||||
|
||||
def test_a_save_that_fails_is_said_on_the_page_not_raised(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""An unwritable destination leaves the block readable and explains itself.
|
||||
|
||||
The block on screen is still copyable, which is the whole reason that path exists
|
||||
alongside the file — so a failed write must never take the page down with it.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import SAVE_KEY
|
||||
|
||||
# A path whose "directory" is an existing file: mkdir and write both refuse, on
|
||||
# every OS, without needing permissions the suite cannot count on.
|
||||
# A path whose "directory" is an existing file: mkdir and write both refuse, on every
|
||||
# OS, without needing permissions the suite cannot count on.
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("not a directory", encoding="utf-8")
|
||||
page = _page(ctx, save_path=blocker / "inside" / "out.txt")
|
||||
session = _Session()
|
||||
page = _page(ctx, session=session, save_path=blocker / "inside" / "out.md")
|
||||
|
||||
page.handle("text", SAVE_KEY)
|
||||
body = _body(page)
|
||||
assert "could not save" in body
|
||||
assert "meshterm" in body # the block itself is untouched
|
||||
assert "\u2717" in session.messages[0]
|
||||
assert "could not save" in session.messages[0]
|
||||
assert "Build" in _body(page) # the page itself is untouched
|
||||
|
||||
|
||||
def test_the_save_key_is_the_only_letter_the_page_claims(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""Any other letter falls through to the scroll handler rather than doing something."""
|
||||
page = _page(ctx, save_path=tmp_path / "out.txt")
|
||||
def test_a_held_save_key_does_not_stack_popups(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""One save at a time: a second press while one is in flight is ignored."""
|
||||
from meshterm.ui.diagnostics import SAVE_KEY
|
||||
|
||||
session = _Session(defer=True)
|
||||
page = _page(ctx, session=session, save_path=tmp_path / "out.md")
|
||||
page.handle("text", SAVE_KEY)
|
||||
page.handle("text", SAVE_KEY)
|
||||
assert len(session.started) == 1
|
||||
|
||||
|
||||
def test_another_letter_falls_through_to_the_pager(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""Any other letter is the scroll handler's, not the save's."""
|
||||
session = _Session()
|
||||
page = _page(ctx, session=session, save_path=tmp_path / "out.md")
|
||||
page.handle("text", "q")
|
||||
assert not (tmp_path / "out.txt").exists()
|
||||
assert "save this to a file" in _body(page)
|
||||
assert not (tmp_path / "out.md").exists()
|
||||
assert session.messages == []
|
||||
|
||||
|
||||
def test_an_empty_listing_is_drawn_by_neither_face() -> None:
|
||||
"""A listing with no rows is absent from the page exactly as it is from the output.
|
||||
# --- the markdown face ------------------------------------------------------------------
|
||||
|
||||
The two faces must not disagree about whether a section exists: a reader comparing a
|
||||
pasted block against their own would otherwise read the difference as meaningful.
|
||||
#: A value that is markdown in every direction: backslashes markdown reads as escapes,
|
||||
#: asterisks and underscores it reads as emphasis. None of it may be reinterpreted.
|
||||
_HOSTILE = r"C:\Users\*jp*\_meshterm_"
|
||||
|
||||
|
||||
def test_every_value_survives_markdown_untouched() -> None:
|
||||
"""Values are code spans, so a path's backslashes and stars arrive as themselves.
|
||||
|
||||
A path, or an error message a stranger's firmware wrote, is not markdown and must never
|
||||
be silently read as it. A value already holding a backtick gets a longer fence.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import render_report
|
||||
source = markdown_blocks(
|
||||
(
|
||||
Facts(
|
||||
key="x",
|
||||
caption="X",
|
||||
fields=(fields.word("p", "p"), fields.word("q", "q")),
|
||||
values={"p": _HOSTILE, "q": "a `backtick` inside"},
|
||||
),
|
||||
)
|
||||
)
|
||||
drawn = _render(render_markdown(source))
|
||||
assert _HOSTILE in drawn
|
||||
assert "a `backtick` inside" in drawn
|
||||
|
||||
empty = (Listing(key="preferences", columns=(), rows=[]),)
|
||||
assert _plain(empty).strip() == ""
|
||||
assert _render(render_report(empty)).strip() == ""
|
||||
|
||||
def test_a_caption_becomes_the_section_heading() -> None:
|
||||
"""A block's caption is its ``##``; a block without one is drawn with no heading."""
|
||||
assert markdown_blocks((Facts(key="x", caption="Radio", fields=()),)).startswith("## Radio")
|
||||
assert not markdown_blocks((Facts(key="x", fields=()),)).startswith("#")
|
||||
|
||||
|
||||
def test_an_empty_section_says_so_rather_than_vanishing() -> None:
|
||||
"""An absence is a fact here: "no preferences changed" is something to act on.
|
||||
|
||||
The plain face drops an empty listing because a record stream has nothing to say about
|
||||
one. A document does — a section silently missing only raises the question of whether
|
||||
it was ever built.
|
||||
"""
|
||||
empty = Listing(key="preferences", caption="Changed preferences", columns=(), rows=[])
|
||||
assert markdown_blocks((empty,)).endswith("*none*")
|
||||
assert "none" in _render(render_markdown(markdown_blocks((empty,))))
|
||||
|
||||
|
||||
# --- the host probe -------------------------------------------------------------------
|
||||
@@ -443,12 +499,45 @@ def _strip(line: str) -> str:
|
||||
return re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||
|
||||
|
||||
def _page(ctx: AppContext, *, save_path: Path | None = None):
|
||||
class _Session:
|
||||
"""A session stand-in that runs the save flow inline and records the popup.
|
||||
|
||||
``run_detached`` is where the real session hands a key handler's flow to the event
|
||||
loop; here it simply runs it, so a test can press a key and assert on what the reader
|
||||
would have been shown. ``defer`` holds the coroutine instead, for the one test that
|
||||
needs a save still in flight.
|
||||
"""
|
||||
|
||||
def __init__(self, *, defer: bool = False) -> None:
|
||||
"""Start with nothing shown and nothing started."""
|
||||
self.messages: list[str] = []
|
||||
self.started: list[object] = []
|
||||
self._defer = defer
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""A repaint request, which a test has no screen to repaint."""
|
||||
|
||||
def run_detached(self, work):
|
||||
"""Run the flow now (or bank it, with ``defer``)."""
|
||||
self.started.append(work)
|
||||
if self._defer:
|
||||
work.close()
|
||||
return None
|
||||
return asyncio.run(work)
|
||||
|
||||
async def message_dialog(self, message, *, title: str = "") -> None:
|
||||
"""Record what the acknowledgement popup would have said."""
|
||||
self.messages.append(str(message))
|
||||
|
||||
|
||||
def _page(ctx: AppContext, *, session: _Session | None = None, save_path: Path | None = None):
|
||||
"""The page over this context's report, saving wherever the caller wants."""
|
||||
from meshterm.ui.diagnostics import DiagnosticsPage
|
||||
|
||||
target = save_path or (ctx.settings.config_dir / "meshterm-diagnostics.txt")
|
||||
return DiagnosticsPage(_report(ctx), save_path=target)
|
||||
target = save_path or (ctx.settings.config_dir / "meshterm-diagnostics.md")
|
||||
return DiagnosticsPage(
|
||||
markdown_source(_report(ctx)), session=session or _Session(), save_path=target
|
||||
)
|
||||
|
||||
|
||||
def _body(page) -> str:
|
||||
|
||||
+20
-13
@@ -987,42 +987,45 @@ def _join_discord(cols: int, rows: int) -> Screen:
|
||||
def _diagnostics(cols: int, rows: int) -> Screen:
|
||||
"""The Diagnostics page, carrying the widest values a real one can.
|
||||
|
||||
A deep Windows config directory and a connection error in the radio's own words are
|
||||
the two fields with no natural bound, and both are here on purpose: this page folds
|
||||
where the scripted face crops, so the fold is what has to survive 53 columns.
|
||||
A deep Windows config directory and a connection error in the radio's own words are the
|
||||
two fields with no natural bound, and both are here on purpose: a markdown list item
|
||||
hangs its wrapped text under itself at any width, and that is what has to survive 53
|
||||
columns.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import DiagnosticsPage
|
||||
|
||||
return DiagnosticsPage(
|
||||
_diagnostics_report(),
|
||||
save_path=Path("C:/Users/somebody/.meshterm/meshterm-diagnostics.txt"),
|
||||
_diagnostics_source(),
|
||||
session=_GallerySession(cols, rows),
|
||||
save_path=Path("C:/Users/somebody/.meshterm/meshterm-diagnostics.md"),
|
||||
)
|
||||
|
||||
|
||||
def _diagnostics_report():
|
||||
"""A representative diagnostics report, shaped exactly as the tool states one."""
|
||||
def _diagnostics_source() -> str:
|
||||
"""A representative diagnostics document, built the way the tool builds one."""
|
||||
from datetime import datetime
|
||||
|
||||
from meshterm.tools.diagnostics import markdown_source
|
||||
from meshterm.ui import fields
|
||||
from meshterm.ui.report import Column, Facts, Lane, Listing
|
||||
|
||||
return (
|
||||
report = (
|
||||
Facts(
|
||||
key="meshterm",
|
||||
caption="Build",
|
||||
fields=(fields.word("meshterm", "meshterm"), fields.word("install", "install")),
|
||||
values={"meshterm": "0.3.7", "install": "frozen"},
|
||||
),
|
||||
Facts(
|
||||
key="host",
|
||||
key="terminal",
|
||||
caption="Terminal",
|
||||
fields=(
|
||||
fields.word("os", "os"),
|
||||
fields.word("python", "python"),
|
||||
fields.word("terminal", "terminal"),
|
||||
fields.word("terminal_size", "terminal_size"),
|
||||
fields.flag("over_ssh", "over_ssh"),
|
||||
fields.word("powerline", "powerline"),
|
||||
),
|
||||
values={
|
||||
"os": "Debian GNU/Linux 12 (bookworm)",
|
||||
"python": "3.11.2",
|
||||
"terminal": "Windows Terminal",
|
||||
"terminal_size": "53x26",
|
||||
"over_ssh": True,
|
||||
@@ -1031,6 +1034,7 @@ def _diagnostics_report():
|
||||
),
|
||||
Facts(
|
||||
key="device",
|
||||
caption="Radio",
|
||||
fields=(
|
||||
fields.flag("connected", "connected"),
|
||||
fields.path("config_dir", "config_dir"),
|
||||
@@ -1044,6 +1048,7 @@ def _diagnostics_report():
|
||||
),
|
||||
Listing(
|
||||
key="tables",
|
||||
caption="Stored rows",
|
||||
columns=(
|
||||
fields.word("table", "TABLE"),
|
||||
Column(key="rows", lanes=(Lane(header="ROWS", render=str, align="right"),)),
|
||||
@@ -1052,10 +1057,12 @@ def _diagnostics_report():
|
||||
),
|
||||
Listing(
|
||||
key="preferences",
|
||||
caption="Changed preferences",
|
||||
columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")),
|
||||
rows=[{"preference": "log_level", "value": "DEBUG"}],
|
||||
),
|
||||
)
|
||||
return markdown_source(report, when=datetime(2026, 9, 19, 17, 0))
|
||||
|
||||
|
||||
def _share_qr(cols: int, rows: int) -> Screen:
|
||||
|
||||
Reference in new issue
Block a user