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

273 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The icon-column guard: in any one list, every icon-led row starts its words in one cell.
The terminal draws some of the app's icons in one cell (``🗑 ✎ ⚙ ▶ ★ ↻ ↕ ⇄ ⌨ # ✓ ✗ ⚠``)
and most emoji in two, so a row written ``icon + " " + label`` starts its words a column
left of its two-cell siblings. The fault is invisible in review — the source reads the same
either way — and it kept coming back screen by screen (the node page's ``🗑 Remove
contact…`` under ``⏳ Time machine``, the main menu, the repeater admin) until the column
was measured once in :mod:`meshterm.ui.menus`: :func:`~meshterm.ui.menus.align_icons` (which
:func:`~meshterm.ui.menus.menu_rows` applies for you), or
:func:`~meshterm.ui.menus.icon_lane` with :func:`~meshterm.ui.menus.icon_mark` /
:func:`~meshterm.ui.menus.marked_label`. This file is what keeps a new list from skipping
them.
**Coverage comes from the gallery, not from a list kept here.** :mod:`tests.test_gallery`
already builds every screen the menu can open, through its real builders, on both
platforms; this sweep imports that inventory (``_ENTRIES`` × ``_COMBOS``) so a screen added
there is guarded here with no edit. Two readings, because the gallery's screens hold their
rows two ways:
* A :class:`~meshterm.ui.tui.select.SelectScreen` holds :class:`~meshterm.ui.tui.select.Choice`
items, so its rows are read as *data*: every choice's natural title, filter or viewport
notwithstanding, including the rows scrolled below the fold.
* A screen that draws its own action rows (the node page, Trace, the TX sweep, a trophy
card) has no items to read, so its body is rendered — under a viewport tall enough that
nothing is clipped — and the list is found by its ``❯`` cursor: the contiguous run of
pointer-column lines (``❯ `` or two spaces, then content) around it, blank lines included.
"Leads with an icon" is :func:`meshterm.ui.menus._icon_head` — the one definition the
aligner and the icon-dropping :func:`~meshterm.ui.menus.command_label` share — and a row's
words start where :func:`meshterm.ui.menus._words_start` says, measured in display cells
with :func:`rich.cells.cell_len`, never characters.
Glyphs that are *data* rather than decoration — a node's ``● ▲ ■ ◉ ○ ★``, a channel's
``# 🌐 🔒`` — get no exemption. They sit in the same column as the command icons around
them (the chat picker pads ``●`` out to ``🌐`` so every conversation name lines up), and a
data lane that went ragged is the same fault the reader sees. The one allowance is structural
and small: in a Choice list, a **blank** :class:`~meshterm.ui.tui.select.Separator` starts a
new group. That blank line is how :func:`~meshterm.ui.menus.exit_rows` sets its ``✓ Apply``
/ ``✗ Back — discard`` pair apart, and how a table sets its command tail apart (the Contacts
list's rows under its maintenance rows, the Courier queue row under its outbox) — the space
says "a different list", and each side is still held to its own column. A section heading
(``── Label ──``) does not break a group: sections of one list read as one column.
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
import pytest
from rich.cells import cell_len
from rich.text import Text
from meshterm.platforms import REGULAR, Platform, set_platform
from meshterm.ui.menus import _icon_head, _words_start, align_icons, exit_rows, menu_rows
from meshterm.ui.tui import Choice, Screen, SelectScreen, Separator
from tests.conftest import plain as _plain
from tests.test_gallery import _COMBOS, _ENTRIES, _Entry
#: The pointer column every list draws before its rows: the cursor row's ``❯ ``, or two
#: spaces for the rest (see :class:`~meshterm.ui.tui.select.SelectScreen`). One cell of
#: glyph and one of space, so two cells wherever it appears.
_CURSOR = "❯ "
_GUTTER = " "
#: A viewport taller than any gallery screen's content, so a hand-drawn list pushed below
#: the fold is still in the rendered body the sweep reads.
_TALL_VIEWPORT = 400
@dataclass(frozen=True)
class _IconRow:
"""One icon-led row of a list: its label as the reader sees it, and where its words start.
Attributes:
label: The row's plain text, pointer column removed.
start: The display cell (0-based, from the label's first cell) its words begin in.
"""
label: str
start: int
def _icon_row(label: str) -> _IconRow | None:
"""``label`` measured as an icon row, or ``None`` when it does not lead with an icon."""
head = _icon_head(label)
if not head:
return None
return _IconRow(label, cell_len(label[: _words_start(label, head)]))
def _choice_groups(items: Iterable) -> list[list[_IconRow]]:
"""A Choice list's icon rows, grouped — a blank separator line starts a new group.
See the module docstring for why the blank line (and only the blank line) separates
two lists: it is the seam :func:`~meshterm.ui.menus.exit_rows` draws above its pair.
"""
groups: list[list[_IconRow]] = [[]]
for item in items:
if isinstance(item, Separator):
title = item.title(_TALL_VIEWPORT) if callable(item.title) else item.title
text = title.plain if isinstance(title, Text) else title
if not text.strip():
groups.append([])
continue
if not isinstance(item, Choice):
continue
label = item.label
row = _icon_row(label.plain if isinstance(label, Text) else label)
if row is not None:
groups[-1].append(row)
return [group for group in groups if group]
def _in_pointer_column(line: str) -> bool:
"""Whether a rendered line belongs to a hand-drawn list: blank, or a pointer + content."""
if not line.strip():
return True
return line.startswith((_CURSOR, _GUTTER)) and line[2:3] not in ("", " ")
def _rendered_groups(lines: list[str]) -> list[list[_IconRow]]:
"""The icon rows of every hand-drawn list in a rendered body, one group per list.
A list is anchored by its ``❯`` row and runs, both ways, over every contiguous line in
the pointer column. Blank lines stay inside it: a screen that spaces its actions into
clusters (Trace's compose/explore, width/samples, run) still measures one icon column
for all of them, and the reader reads them as one.
"""
groups: list[list[_IconRow]] = []
for anchor, line in enumerate(lines):
if not line.startswith(_CURSOR):
continue
first = anchor
while first > 0 and _in_pointer_column(lines[first - 1]):
first -= 1
last = anchor
while last + 1 < len(lines) and _in_pointer_column(lines[last + 1]):
last += 1
rows = [_icon_row(row[2:]) for row in lines[first : last + 1] if row.strip()]
groups.append([row for row in rows if row is not None])
return [group for group in groups if group]
def _screen_groups(screen: Screen, cols: int) -> list[list[_IconRow]]:
"""Every list on ``screen``, read as items where it has them and as rendering where not."""
if isinstance(screen, SelectScreen):
return _choice_groups(screen._items)
screen.note_viewport(_TALL_VIEWPORT)
return _rendered_groups(_plain(screen.render_body(cols)).splitlines())
def _misalignments(where: str, groups: list[list[_IconRow]]) -> list[str]:
"""One failure sentence per group whose icon rows start their words in different cells."""
problems = []
for number, group in enumerate(groups, start=1):
if len({row.start for row in group}) < 2:
continue
rows = "\n".join(f" cell {row.start:>2}: {row.label.rstrip()!r}" for row in group)
problems.append(
f"{where}, list {number} of {len(groups)}: icon rows start their words in "
f"different cells —\n{rows}\n"
" Pad the marks to one column: build the labels through menus.align_icons "
"(menu_rows does it for you), or icon_lane + icon_mark / marked_label(lane=...)."
)
return problems
def _cases():
for entry in _ENTRIES:
for platform, cols, rows in _COMBOS:
yield pytest.param(
entry, platform, cols, rows, id=f"{entry.name}-{platform.name}-{cols}x{rows}"
)
@pytest.mark.parametrize("entry,platform,cols,rows", list(_cases()))
def test_icon_rows_share_one_column(
entry: _Entry, platform: Platform, cols: int, rows: int
) -> None:
"""Within one list, every icon-led row starts its words in the same display cell.
Swept over the whole gallery on both platforms. The PicoCalc drops a command row's icon
lane outright, but its data glyphs (a node's type, a channel's openness) stay and fold to
one-cell stand-ins, so the rule still has something to hold there.
"""
set_platform(platform)
screen = entry.factory(cols, rows)
where = f"{entry.name} on {platform.name} {cols}x{rows}"
problems = _misalignments(where, _screen_groups(screen, cols))
assert not problems, "\n\n".join(problems)
@pytest.mark.parametrize("platform", sorted({p for p, _, _ in _COMBOS}, key=lambda p: p.name))
def test_the_sweep_measures_something_on_every_platform(platform: Platform) -> None:
"""The guard is not vacuous: each platform compares real lists, on each reading it can.
A gallery refactor that hid a screen's items, or a render change that moved the ``❯``
pointer, would otherwise leave every case above passing because it found nothing to
measure. On the desktop both readings must reach a list of two or more icon rows; on the
PicoCalc the hand-drawn action lists carry no icons at all (the lane is dropped), so only
the Choice reading — node-type and channel glyphs, the exit pair — is required there.
"""
set_platform(platform)
cols, rows = next((c, r) for p, c, r in _COMBOS if p is platform)
measured = {"items": 0, "rendered": 0}
for entry in _ENTRIES:
screen = entry.factory(cols, rows)
reading = "items" if isinstance(screen, SelectScreen) else "rendered"
measured[reading] += sum(len(g) > 1 for g in _screen_groups(screen, cols))
assert measured["items"] > 0, f"no Choice list with 2+ icon rows on {platform.name}"
if platform is REGULAR:
assert measured["rendered"] > 0, "no hand-drawn list with 2+ icon rows on regular"
def test_guard_catches_a_hand_written_icon_prefix() -> None:
"""A list written ``icon + " " + label`` fails, naming both rows and both start cells.
``🗑`` is one cell and ``💾`` two: the exact pair that put the node page's delete row out
of line under its archive row. If this stops failing, the guard has stopped guarding.
"""
screen = SelectScreen("Node", [Choice("💾 Archive contact", 1), Choice("🗑 Delete contact…", 2)])
problems = _misalignments("node page", _screen_groups(screen, 72))
assert len(problems) == 1
assert "cell 3: '💾 Archive contact'" in problems[0]
assert "cell 2: '🗑 Delete contact…'" in problems[0]
aligned = SelectScreen(
"Node",
[
Choice(label, n)
for n, label in enumerate(align_icons(["💾 Archive contact", "🗑 Delete contact…"]))
],
)
assert not _misalignments("node page", _screen_groups(aligned, 72))
def test_guard_catches_a_hand_drawn_list_through_its_rendering() -> None:
"""A screen with no items is read off its rendered body, the pointer column removed.
The cursor row and its gutter siblings are one list across a blank line, and the text
above (a card, not in the pointer column) is not part of it.
"""
lines = [
"route ★ → 3d63 → ★",
"",
" ✎ Compose path",
" ⚡ Explore paths",
"",
"❯ ▶ Trace — one transmission",
"",
"Per-hop medians",
" ↓ 2 more",
]
groups = _rendered_groups(lines)
assert [[row.start for row in group] for group in groups] == [[2, 3, 2]]
assert _misalignments("trace", groups)
def test_exit_pair_is_its_own_group() -> None:
"""The staged-changes pair after its blank line is measured apart from the rows above it.
``✓``/``✗`` are one-cell status marks on a commit row, never padded to a list's emoji
column (see :func:`~meshterm.ui.menus.exit_rows`); the blank separator is the seam. The
pair is still held to its own column, and so are the rows above it.
"""
items = menu_rows([("📡 Send advert", "Flood it", 1), ("⌨ Command line", "Raw CLI", 2)])
items += exit_rows(2, apply_value="apply", back_value="back")
groups = _choice_groups(items)
assert [[row.start for row in group] for group in groups] == [[3, 3], [2, 2]]
assert not _misalignments("editor", groups)