Files
MeshTerm/tests/test_markdown.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

314 lines
12 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The markdown renderer: what each construct becomes in MeshTerm's visual language.
The About pages are written rather than composed (see :mod:`meshterm.ui.markdown`), so
what is worth pinning here is the *translation* — that a heading takes the same accent
every body section uses, that prose and lists hang in their own block instead of falling
back to column 0, that a rail survives wrapping, that a link says where it goes on a
terminal where nothing is clickable — and that the whole vocabulary survives the
PicoCalc's 512 glyphs and 16 palette slots.
"""
from __future__ import annotations
import pytest
from rich.cells import cell_len
from rich.padding import Padding
from rich.text import Text
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.fontset import FONT_CODEPOINTS
from meshterm.ui.markdown import MarkdownDoc, render_markdown
from meshterm.ui.tui.render import render_lines
from tests.conftest import plain
#: One page exercising every construct the renderer knows, used wherever a test wants
#: the whole vocabulary at once rather than one mark of it.
KITCHEN_SINK = """# MeshTerm *v0.1.0*
a terminal companion for MeshCore LoRa mesh devices
## What it is · the short version
MeshTerm is a **full-screen** companion for *MeshCore* devices, with `traces`,
maps and a ~~telepathic~~ courier queue over the top.
### Under the hood
- contacts, sorted however you like
- discovered nodes, and the ones you added
- a map you can walk with the arrow keys
1. plug the radio in
2. run `meshterm`
> Nothing here transmits without being asked.
```
meshterm --mock --platform picocalc
```
| Platform | Cols | Colours |
|---|---:|---|
| regular | 72 | truecolor |
Find it at [the repo](https://github.com/example/meshterm).
---
Copyright © 2026 Somebody
"""
def _lines(source: str, width: int = 72) -> list[str]:
"""The page as a reader sees it: plain text, one entry per rendered line.
Trailing padding is dropped — an indented block pads out to the full width, which no
reader can see and no assertion here is about.
"""
rendered = plain(render_lines(render_markdown(source), width)).splitlines()
return [line.rstrip() for line in rendered]
def _texts(source: str) -> list[Text]:
"""Every block of the page that is a run of text, unwrapped from its indent."""
texts = []
for block in render_markdown(source).blocks:
renderable = block.renderable
while isinstance(renderable, Padding):
renderable = renderable.renderable
if isinstance(renderable, Text):
texts.append(renderable)
return texts
def _spans(source: str) -> list[tuple[str, str]]:
"""Every styled run in the page's block texts, as ``(text, style)`` pairs."""
return [
(text.plain[span.start : span.end], str(span.style))
for text in _texts(source)
for span in text.spans
]
# -- headings and the page frame ------------------------------------------------------
@pytest.mark.parametrize(
("source", "style"),
[("# Title", "brand"), ("## Section", "accent"), ("### Sub", "md.strong")],
)
def test_each_heading_level_takes_the_hue_its_rank_calls_for(source, style) -> None: # noqa: ANN001
"""Each heading level takes the hue its rank calls for.
``#`` is the page's own name, ``##`` a section in the app's body accent, and
``###`` a sub-heading inside one.
"""
assert _spans(source)[0][1] == style
def test_a_headings_dot_opens_the_standard_muted_aside() -> None:
"""``## Title · note`` is the ``accent title · muted note`` shape body headings use."""
doc = render_markdown("## What it is · the short version")
heading = doc.blocks[0].renderable
assert heading.plain == "What it is · the short version"
assert [str(span.style) for span in heading.spans][-1] == "muted"
def test_emphasis_inside_a_heading_is_its_qualifier() -> None:
"""That is how the wordmark carries a muted version beside it, in one line of source."""
doc = render_markdown("# MeshTerm *v0.1.0*")
assert doc.blocks[0].renderable.plain == "MeshTerm v0.1.0"
assert [str(span.style) for span in doc.blocks[0].renderable.spans] == ["brand", "muted"]
def test_the_page_frame_sits_flush_and_muted() -> None:
"""The page frame sits flush and muted.
The standfirst under the title and the colophon under the closing rule describe
the page rather than saying anything in it, so both recede and neither is
indented.
"""
source = "# Title\n\nthe standfirst\n\n## Section\n\nbody\n\n---\n\nthe colophon\n"
lines = _lines(source)
styles = dict(_spans(source))
assert lines[1] == "the standfirst" # flush, not hanging at INDENT
assert lines[-1] == "the colophon"
assert styles["the standfirst"] == "muted" and styles["the colophon"] == "muted"
def test_a_paragraph_is_only_a_colophon_at_the_foot_of_the_page() -> None:
"""A rule mid-page is just a rule; the paragraph under it is ordinary prose."""
lines = _lines("## Section\n\n---\n\nstill body\n\nand more\n")
assert " still body" in lines
# -- prose, lists, rails --------------------------------------------------------------
def test_prose_hangs_under_its_heading_on_every_wrapped_line() -> None:
"""A continuation line falling back to column 0 would read as a new, unheaded thought."""
lines = [line for line in _lines("## S\n\n" + "word " * 40, 30) if line.strip()]
assert len(lines) > 3 # it really did wrap
assert all(line.startswith(" ") for line in lines[1:]), lines
def test_a_wrapped_list_item_aligns_under_its_own_text() -> None:
"""The bullet is a gutter, so the item's second line starts where its first line did."""
lines = [line for line in _lines("- " + "word " * 20, 30) if line.strip()]
assert lines[0].startswith(" • word")
assert all(line.startswith(" word") for line in lines[1:]), lines
def test_a_nested_list_steps_in_under_the_item_it_belongs_to() -> None:
"""And takes the sub-item bullet, so depth reads without counting spaces."""
lines = _lines("- parent\n - child\n")
assert lines[0] == " • parent"
assert lines[1].startswith(" ▸ child")
def test_a_numbered_list_counts_from_its_own_start() -> None:
"""Markdown's ``3.`` means three, and a two-digit run keeps its numbers aligned."""
lines = [line for line in _lines("9. nine\n10. ten\n") if line.strip()]
assert lines[0].startswith(" 9. nine")
assert lines[1].startswith(" 10. ten")
def test_a_quote_keeps_its_rail_down_every_line_it_wraps_to() -> None:
"""A quote keeps its rail down every line it wraps to.
A rail that only marked the first line would make a three-line quote look like
one quoted line followed by two loose ones.
"""
lines = [line for line in _lines("> " + "word " * 20, 30) if line.strip()]
assert len(lines) > 2
assert all(line.startswith(" │ ") for line in lines), lines
def test_a_fenced_block_keeps_its_own_lines() -> None:
"""Code is quoted verbatim, one rendered line per source line."""
lines = [line for line in _lines("```\none\ntwo\n```\n") if line.strip()]
assert [line.rstrip() for line in lines] == [" │ one", " │ two"]
def test_a_qr_fence_draws_the_code_rather_than_the_url_inside_it() -> None:
"""The one fence that isn't quoted: its body is a link, and the page shows the code.
Drawn live from the fence, never pasted in as half-block art — art loses a row whose
first module is light, because that row starts with a space and markdown eats it.
"""
source = """```qr
https://example.com/x
```
"""
lines = [line for line in _lines(source) if line.strip()]
assert lines, "the fence rendered nothing"
assert not any("example.com" in line for line in lines), "the URL was printed as text"
assert all(set(line.strip()) <= set("█▀▄ ") for line in lines), lines
# Two of the three finder squares sit at either end of the code's first module row.
top = lines[0].strip()
assert top.startswith("█▀▀▀▀▀█") and top.endswith("█▀▀▀▀▀█"), top
def test_a_rule_runs_the_full_width_of_the_page() -> None:
"""``---`` is a page rule, not an indented one — it separates, so it spans."""
line = next(line for line in _lines("a\n\n---\n\nb\n", 40) if "─" in line)
assert line == "─" * 40
# -- inline marks ---------------------------------------------------------------------
def test_each_inline_mark_takes_its_own_style() -> None:
"""Strong, emphasis, code and a struck-out run are four voices, not one."""
styles = dict(_spans("**bold** *soft* `code` ~~gone~~\n"))
assert styles["bold"] == "md.strong"
assert styles["soft"] == "md.em"
assert styles["code"] == "md.code"
assert styles["gone"] == "md.strike"
def test_a_link_shows_where_it_goes() -> None:
"""A link shows where it goes.
Nothing is clickable on a framebuffer console, so a link that reads "the repo" has
to name the repo or it says nothing at all — and with the scheme stripped, it is
noise.
"""
text = _texts("see [the repo](https://www.github.com/example/meshterm/)\n")[0]
assert text.plain == "see the repo github.com/example/meshterm"
def test_a_link_that_already_says_its_target_says_it_once() -> None:
"""A bare URL or an autolink is its own label; repeating it would just eat cells."""
text = _texts("mail <hello@example.com> or <https://example.com>\n")[0]
assert text.plain == "mail hello@example.com or https://example.com"
def test_a_table_aligns_the_way_its_markdown_says() -> None:
"""``---:`` is a right-aligned column, and the header keeps the body's own hairline."""
lines = [line for line in _lines("| A | B |\n|---|--:|\n| 1 | 22222 |\n", 30) if line.strip()]
assert lines[0].split() == ["A", "B"]
assert "─" in lines[1]
assert lines[2].rstrip().endswith("22222")
# -- the document itself --------------------------------------------------------------
def test_sections_are_the_pages_h2_headings() -> None:
"""The landmarks a sticky heading and a section jump step by."""
doc = render_markdown("# Title\n\n## One\n\na\n\n### deeper\n\n## Two\n\nb\n")
assert doc.sections == ("One", "Two")
def test_a_page_written_entirely_in_top_level_headings_still_has_sections() -> None:
"""One authoring style shouldn't silently cost a page its landmarks."""
assert render_markdown("# One\n\na\n\n# Two\n\nb\n").sections == ("One", "Two")
def test_the_document_is_a_plain_renderable_too() -> None:
"""The scripted CLI prints the same page it opens (``meshterm about``)."""
assert isinstance(render_markdown(KITCHEN_SINK), MarkdownDoc)
assert _lines(KITCHEN_SINK)[0] == "MeshTerm v0.1.0"
# -- the console --------------------------------------------------------------------
def test_every_construct_survives_the_picocalc() -> None:
"""The whole vocabulary inside 53 cells, the 512-glyph font and the 16 slots.
The gallery makes this promise for the About pages themselves; this makes it for
every construct a page *could* grow, so a table or a code fence added to one of them
is a known quantity rather than tofu found on-device.
"""
set_platform(PICOCALC)
try:
rendered = render_lines(render_markdown(KITCHEN_SINK), PICOCALC.readable_cols)
finally:
set_platform(REGULAR)
for index, line in enumerate(rendered):
assert cell_len(plain(line)) <= PICOCALC.readable_cols, (index, line)
assert "[38;2;" not in line and "[38;5;" not in line, (index, line)
strays = {ch for ch in plain(line) if ord(ch) >= 0x20 and ord(ch) not in FONT_CODEPOINTS}
assert not strays, (index, sorted(strays), line)