Files
MeshTerm/tests/test_icon_alignment.py
Jean-Pierre MartineauandClaude Opus 5.5 11b5274b31 Docstrings and comments of the tests are in ASD-STE100
Each test docstring states the behaviour that the test proves, in the
simple present. No assertion message, test id, or other string literal
changed. Four statements now agree with the code: the Trace highlight
stays on the last row, a menu count, and two others.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 21:36:50 -04:00

287 lines
13 KiB
Python
Raw Permalink 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 one list, each icon row starts its words in one cell.
An icon row is a row that starts with an icon. The terminal draws some of the icons of the
app in one cell (``🗑 ✎ ⚙ ▶ ★ ↻ ↕ ⇄ ⌨ # ✓ ✗ ⚠``) and most emoji in two cells. Thus a row
that is written ``icon + " " + label`` starts its words one column to the left of its
two-cell neighbours. A review cannot show this fault,
because the source looks the same in both cases. The fault returned again and again, screen
by screen (the node page's ``🗑 Remove contact…`` under ``⏳ Time machine``, the main menu,
the repeater admin). Then we measured the column one time in :mod:`meshterm.ui.menus`. The
tools are :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` and
:func:`~meshterm.ui.menus.marked_label`. This file makes sure that a new list does not skip
them.
**The coverage comes from the gallery, not from a list in this file.**
:mod:`tests.test_gallery` already builds each screen that the menu can open, through its
real builders, on both platforms. This sweep imports that inventory (``_ENTRIES`` ×
``_COMBOS``). Thus a screen that is added there is guarded here with no edit. The sweep has
two readings, because the screens in the gallery hold their rows in two ways:
* A :class:`~meshterm.ui.tui.select.SelectScreen` holds :class:`~meshterm.ui.tui.select.Choice`
items, so the sweep reads its rows as *data*. It reads the natural title of each choice,
with no effect from the filter or the viewport. This includes the rows 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. Thus the sweep renders its body in a viewport that is tall
enough to clip nothing. It finds the list by its ``❯`` pointer: the continuous run of
pointer-column lines (``❯ `` or two spaces, then content) around it, with the blank
lines.
"Starts with an icon" is :func:`meshterm.ui.menus._icon_head`. The aligner and the
:func:`~meshterm.ui.menus.command_label` function (which removes the icon) use this same
definition. The words of a row start where :func:`meshterm.ui.menus._words_start` says.
The sweep measures this in display cells with :func:`rich.cells.cell_len`, never in
characters.
A glyph that is *data* and not decoration gets no exemption. Examples are the ``● ▲ ■ ◉ ○ ★``
of a node and the ``# 🌐 🔒`` of a channel. These glyphs are in the same column as the
command icons around them (the chat picker pads ``●`` to the width of ``🌐``, so each
conversation name is in line). A data lane that is not aligned is the same fault that the
user sees. There is one small allowance, and it is structural. In a Choice list, a
**blank** :class:`~meshterm.ui.tui.select.Separator` starts a new group. This blank line is
how :func:`~meshterm.ui.menus.exit_rows` sets its ``✓ Apply`` and ``✗ Back — discard`` pair
apart. It is also how a table sets its command tail apart (the rows of the Contacts list
under its maintenance rows, the Courier queue row under its outbox). The space means "a
different list", and each side keeps its own column. A section heading (``── Label ──``)
does not break a group: the sections of one list have 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 that each list draws before its rows: the ``❯ `` of the highlight,
#: or two spaces for the other rows (refer to :class:`~meshterm.ui.tui.select.SelectScreen`).
#: It has one cell of glyph and one cell of space, thus two cells in each place.
_CURSOR = "❯ "
_GUTTER = " "
#: A viewport that is taller than the content of each gallery screen. Thus a hand-drawn
#: list below the fold is still in the rendered body that the sweep reads.
_TALL_VIEWPORT = 400
@dataclass(frozen=True)
class _IconRow:
"""One row of a list that starts with an icon.
The row has its label as the user sees it, and the cell where its words start.
Attributes:
label: The plain text of the row, with no pointer column.
start: The display cell (0-based, from the first cell of the label) where its words
start.
"""
label: str
start: int
def _icon_row(label: str) -> _IconRow | None:
"""``label`` measured as an icon row, or ``None`` if it does not start 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]]:
"""The icon rows of a Choice list, in groups. A blank separator line starts a new group.
Refer to the module docstring for the reason that the blank line (and only the blank
line) separates two lists: it is the seam that :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 is part of a hand-drawn list: blank, or a pointer and 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 each hand-drawn list in a rendered body, with one group for each list.
The ``❯`` row is the anchor of a list. The list goes up and down over each continuous
line in the pointer column. Blank lines stay in the list. A screen can put its actions
in clusters (Trace's compose and explore, width and samples, run). It still has one icon
column for all of them, and the user reads them as one list.
"""
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]]:
"""Each list on ``screen``.
The sweep reads items if the screen has them, and the rendering if 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 for each group whose icon rows start 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:
"""In one list, each row that starts with an icon starts its words in the same display cell.
The test sweeps the whole gallery on both platforms. The PicoCalc removes the icon lane
of a command row completely. But its data glyphs (the type of a node, the openness of a
channel) stay and fold to one-cell substitutes. Thus the rule still applies 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 empty: each platform compares real lists, in each reading that it can.
Suppose a gallery refactor hides the items of a screen, or a render change moves the
``❯`` pointer. Then each case above passes, because it finds nothing to measure. This
test finds that fault. On the desktop, both readings must reach a list of two or more
icon rows. On the PicoCalc, the hand-drawn action lists have no icons (the lane is
removed). Thus there the test needs only the Choice reading: the node-type glyphs, the
channel glyphs, and the exit pair.
"""
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 that is written ``icon + " " + label`` fails.
The failure names both rows and both start cells. ``🗑`` is one cell and ``💾`` is two
cells. This exact pair put the delete row of the node page out of line under its archive
row. If this test stops to fail, the guard does not guard.
"""
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:
"""The sweep reads a screen with no items from its rendered body, with no pointer column.
The highlighted row and the rows with a gutter are one list across a blank line. The
text above them (a card, not in the pointer column) is not part of the list.
"""
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 test measures the staged-changes pair after its blank line apart from the rows above it.
``✓`` and ``✗`` are one-cell status marks on a commit row. MeshTerm does not pad them to
the emoji column of a list (refer to :func:`~meshterm.ui.menus.exit_rows`). The blank
separator is the seam. The pair keeps its own column, and the rows above it keep theirs.
"""
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)