Files
MeshTerm/tests/test_emoji_width.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

312 lines
16 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Terminal-aligned emoji width: VS16 sequences and curated lone-codepoint emoji.
The border-alignment story lives in :mod:`meshterm.ui.tui.emoji_width`. These tests pin the
two things that make a chat row with ``👋`` frame flush: Rich must *measure* the glyph as one
cell (so it pads the row to the right width) and prompt_toolkit must *count* it as one (so it
places the panel's right border where the terminal actually draws it). An emoji the terminal
draws two wide — a menu icon like ``📡``, never on the allowlist — must stay two in both.
The factory helpers (``_make_cell_len``, ``_make_pt_cache``) are exercised in isolation so no
process-global width table is touched; the two :func:`calibrate` tests that do patch the real
tables snapshot and restore them in a ``finally`` so nothing leaks into a later test.
"""
from __future__ import annotations
import prompt_toolkit.utils as ptu
import rich.cells as cells
from meshterm.ui.tui import emoji_width as ew
# One confirmed narrow glyph, plus one the same terminal draws two wide, used throughout.
_WAVE = "👋" # the confirmed lone-codepoint emoji this terminal draws in one cell
_DISH = "📡" # a menu icon the terminal draws two wide — must never be narrowed
_CA = "🇨🇦" # a flag: two Regional Indicators; prompt_toolkit miscounts it as four cells
_CN = "🇨🇳" # a second flag sharing the "C" indicator — the whole category must be handled
_PLANE = "🛩️" # a VS16 sequence this terminal draws two wide despite the narrow-VS16 verdict
_PLANE_BASE = "\U0001f6e9" # its base codepoint (no selector) — what the wide set is keyed on
_ROAD = "\U0001f6e3" # 🛣 a bare text-default emoji: one cell, and both authorities agree
_WEB = "\U0001f578" # 🕸 its untouched sibling one Trophy board down — the control
_SHRUG = "\U0001f937‍♂️" # 🤷‍♂️ a ZWJ sequence: base, joiner, male sign, selector — one glyph
_FAMILY = "\U0001f468‍\U0001f469‍\U0001f467" # 👨‍👩‍👧 three joined people, still one glyph
_ZWJ = "‍" # the joiner itself: the tell that its neighbours are a single glyph
def test_narrow_lone_set_defaults_extends_and_disables(monkeypatch) -> None:
"""The allowlist seeds to the confirmed glyph; the env var overrides it outright."""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
assert _WAVE in ew._narrow_lone_set()
monkeypatch.setenv("MESHTERM_NARROW_EMOJI", "👋🤙")
assert {"👋", "🤙"} <= ew._narrow_lone_set()
# Empty string is the off switch — a way back to Rich/pt defaults with no code change.
monkeypatch.setenv("MESHTERM_NARROW_EMOJI", "")
assert ew._narrow_lone_set() == frozenset()
def test_wide_base_set_defaults_extends_and_disables(monkeypatch) -> None:
"""The wide set seeds to the confirmed bases; the env var overrides it outright."""
monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False)
# One source only: a VS16 sequence the narrow verdict gets wrong. A *bare* codepoint both
# authorities already measure at one is not a candidate — see test_bare_text_default_emoji.
assert _PLANE_BASE in ew._wide_base_set()
assert not {_ROAD, _WEB} & ew._wide_base_set()
# ✈ (U+2708) is a second VS16 airplane base; both list cleanly.
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", "\U0001f6e9✈")
assert {"\U0001f6e9", "✈"} <= ew._wide_base_set()
# Pasting the whole rendered glyph keeps the base but strips the zero-width selector, so
# the selector is never itself counted as a wide cell.
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", _PLANE)
wide = ew._wide_base_set()
assert _PLANE_BASE in wide and "️" not in wide
# Empty string trusts the narrow-VS16 verdict for every sequence.
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", "")
assert ew._wide_base_set() == frozenset()
def test_rich_cell_len_narrows_only_allowlisted_lone_emoji() -> None:
"""Rich's replacement measures a listed lone emoji as one, an unlisted one still as two."""
cell_len = ew._make_cell_len(frozenset(_WAVE), frozenset(_PLANE_BASE))
assert cell_len(_WAVE) == 1
assert cell_len(_DISH) == 2 # unlisted: the terminal draws it wide, so leave it wide
assert cell_len("Bob 👋 hi") == 8 # the narrowed glyph flows through a whole line
# The pre-existing VS16 handling still applies: a narrow base is measured alone, its
# variation selector skipped, so "☀️" stays one cell rather than being promoted to two.
assert cell_len("☀️") == 1
# A wide-VS16 exception is forced back to two, selector still skipped, so the airplane
# frames flush instead of collapsing to one and smearing the row.
assert cell_len(_PLANE) == 2
assert cell_len("hi 🛩️") == len("hi ") + 2
# A ZWJ sequence is one glyph, measured as its base: the codepoints the joiner folds in
# cost nothing, however many of them there are. Rich's stock loop already does this, and
# the selector-skipping replacement must not lose it — counting the male sign as a cell
# of its own is what pulls the row's right border a column in.
assert cell_len(_SHRUG) == 2
assert cell_len(_FAMILY) == 2
assert cell_len(f"Bob {_FAMILY} hi") == len("Bob hi") + 2
def test_pt_cache_narrows_only_allowlisted_lone_emoji() -> None:
"""prompt_toolkit's cache — the authority that places the border — matches Rich."""
cache = ew._make_pt_cache(frozenset(_WAVE), frozenset(_PLANE_BASE))
assert cache[_WAVE] == 1
assert cache[_DISH] == 2
# The base cache sums a multi-char string per character through the cache, so the
# one-cell wave is inherited by any line that contains it.
assert cache["Bob 👋"] == 5
# The wide-VS16 airplane is the reverse: its base is forced to two, the selector stays
# zero, so the whole glyph (and any line holding it) keeps the terminal's two cells.
assert cache[_PLANE] == 2
assert cache["🛩️ hi"] == 2 + len(" hi")
# The sequences prompt_toolkit added up part by part: three cells for the shrug and six
# for the family, each of them one two-cell glyph, each pulling a border in by the excess.
assert cache[_SHRUG] == 2
assert cache[_FAMILY] == 2
assert cache[f"Bob {_FAMILY} hi"] == len("Bob hi") + 2
# A lone joiner is still zero, and a codepoint a sequence joins keeps its own width when
# it stands alone — ``↕`` is the reorder icon, not part of anything.
assert cache[_ZWJ] == 0
assert cache["↕"] == 1
def test_bare_text_default_emoji_are_left_exactly_as_measured() -> None:
"""An emoji outside Emoji_Presentation, drawn *bare*, is one cell — and stays one.
``🛣`` and ``🕸`` have East-Asian width Neutral, so Rich and wcwidth both measure them at
one; with no variation selector asking for emoji presentation, the font draws a one-cell
text glyph, which is exactly what they said. Both authorities already agreeing is not a
bug to correct: listing ``🛣`` in the wide set made the Trophy case's Longest-distance
heading reserve a cell the terminal never drew and pulled that row's border a column in,
while ``🕸`` — same class, one board down, never listed — framed flush throughout.
"""
cell_len = ew._make_cell_len(frozenset(_WAVE), ew._wide_base_set())
cache = ew._make_pt_cache(frozenset(_WAVE), ew._wide_base_set())
for bare in (_ROAD, _WEB):
assert cell_len(bare) == 1 and cache[bare] == 1
# A heading built around one costs its own cells and no more, so the row frames flush.
caption = cell_len(f"── {_ROAD} Longest distance ──")
assert caption == len("── ") + 1 + len(" Longest distance ──")
def test_flags_measure_two_cells_in_both_authorities() -> None:
"""A country flag measures two cells in both width authorities.
A flag is a Regional Indicator pair: prompt_toolkit's wcwidth calls it four cells,
while Rich and the terminal draw it as one two-cell glyph. Narrowing the
indicators as a category makes every flag sum to two in both — no per-country
allowlist entry, and flags sharing an indicator (🇨🇦 / 🇨🇳) are fixed at once.
"""
# Only the lone-emoji allowlist is passed; flags are handled by category, not by listing.
cell_len = ew._make_cell_len(frozenset(_WAVE), frozenset())
cache = ew._make_pt_cache(frozenset(_WAVE), frozenset())
for flag in (_CA, _CN):
assert cell_len(flag) == 2
assert cache[flag] == 2 # was 4 unpatched: two indicators at wcwidth 2 each
# A flag rides a chat line without dragging the border: "eh? 🇨🇦" measures its plain
# cells plus the flag's two.
assert cell_len("eh 🇨🇦") == len("eh ") + 2
def _snapshot() -> tuple:
"""Capture the mutable width state :func:`calibrate` patches, to restore afterwards."""
return (cells._cell_len, ptu._CHAR_SIZES_CACHE, ew._CALIBRATED, ew._CLUSTERS)
def _restore(snap: tuple) -> None:
"""Put the width authorities (and the once-only flag) back, clearing Rich's memo cache."""
cells._cell_len, ptu._CHAR_SIZES_CACHE, ew._CALIBRATED, ew._CLUSTERS = snap
cells.cached_cell_len.cache_clear()
def test_calibrate_width1_narrows_the_wave_in_both_authorities(monkeypatch) -> None:
"""A renderer that draws emoji narrow gets both Rich and pt aligned to the terminal."""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False)
from prompt_toolkit.utils import get_cwidth
snap = _snapshot()
try:
ew._CALIBRATED = False
ew.calibrate(force_width=1)
assert cells.cell_len(_WAVE) == 1
assert cells.cell_len(_DISH) == 2 # unlisted icon stays wide
assert cells.cell_len(_CA) == 2 # flag handled by category, no allowlist entry
assert cells.cell_len(_PLANE) == 2 # wide-VS16 exception carved back out of the narrowing
assert cells.cell_len(_ROAD) == 1 # bare text-default emoji: left exactly as measured
assert cells.cell_len(_WEB) == 1
assert get_cwidth(_WAVE) == 1 # pt now places the border a cell earlier
assert get_cwidth(_DISH) == 2
assert get_cwidth(_CA) == 2 # was 4 unpatched
assert get_cwidth(_PLANE) == 2 # was 1 unpatched: the airplane smeared a cell short
assert get_cwidth(_ROAD) == 1 and get_cwidth(_WEB) == 1 # untouched, in both authorities
assert cells.cell_len(_SHRUG) == 2 and get_cwidth(_SHRUG) == 2 # pt counted three
assert cells.cell_len(_FAMILY) == 2 and get_cwidth(_FAMILY) == 2 # pt counted six
finally:
_restore(snap)
def test_calibrate_width2_narrows_nothing_but_still_joins_clusters(monkeypatch) -> None:
"""A width-2 terminal narrows nothing, but still gets the cluster join.
Narrowing would itself break the border there — but a joined sequence is
over-measured at *every* width, so that one correction still lands.
prompt_toolkit sums a ZWJ sequence's codepoints wherever it runs, which no terminal draws:
the family is one two-cell glyph on the widest terminal as surely as on the narrowest. So
the cluster rule is installed on its own here, and nothing else is — the curated sets and
the flag category stay unconfirmed on this renderer, and Rich is not touched at all.
"""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
from prompt_toolkit.utils import get_cwidth
snap = _snapshot()
try:
ew._CALIBRATED = False
ew.calibrate(force_width=2)
assert cells.cell_len(_WAVE) == 2
assert get_cwidth(_WAVE) == 2
# Rich untouched: it still promotes a VS16 sequence to the two cells this terminal draws.
assert cells.cell_len(_PLANE) == 2 and cells.cell_len("☀️") == 2
# Neither curated set nor the flag category applies here — pt keeps its stock answers.
assert get_cwidth("☀️") == 1
assert get_cwidth(_CA) == 4
# The one correction that holds at either width.
assert get_cwidth(_SHRUG) == 2 # was 3
assert get_cwidth(_FAMILY) == 2 # was 6
finally:
_restore(snap)
def test_join_zwj_clusters_merges_only_joined_runs() -> None:
"""The fragment merge gathers a whole sequence and leaves everything else alone.
prompt_toolkit's ANSI text arrives one codepoint per fragment, so a sequence is a run:
a base, an optional selector, then *joiner + component + optional selector* groups.
"""
def line(text: str) -> list:
return [("", char) for char in text]
def texts(fragments: list) -> list:
return [text for _style, text in fragments]
# A line with no joiner is handed straight back — the same object, not a copy.
plain = line("hi 📡")
assert ew._join_zwj_clusters(plain) is plain
# The shrug's four fragments become one; the bars around it are untouched.
assert texts(ew._join_zwj_clusters(line(f"|{_SHRUG}|"))) == ["|", _SHRUG, "|"]
# Three joined people, five fragments, still one glyph.
assert texts(ew._join_zwj_clusters(line(_FAMILY))) == [_FAMILY]
# A selector on the *base*, before the joiner, belongs to the run: ❤️‍🔥.
burning = "❤️‍\U0001f525"
assert texts(ew._join_zwj_clusters(line(burning))) == [burning]
# A bare VS16 pair is not a sequence — prompt_toolkit already folds it into the cell
# before it, so those fragments are left exactly as they came.
assert texts(ew._join_zwj_clusters(line(f"☀️{_SHRUG}"))) == ["☀", "️", _SHRUG]
# A trailing joiner with nothing to join is not a sequence either.
assert texts(ew._join_zwj_clusters(line(f"a{_ZWJ}"))) == ["a", _ZWJ]
# A merged fragment iterates as the whole sequence, which is what makes prompt_toolkit
# build one Char of it instead of one per codepoint.
(merged,) = texts(ew._join_zwj_clusters(line(_SHRUG)))
assert list(merged) == [_SHRUG] and merged == _SHRUG
def test_cluster_control_lays_a_sequence_into_a_single_screen_cell(monkeypatch) -> None:
"""The delivery half: one ``Char`` per glyph, with every codepoint still written out.
Measuring the sequence right is not enough on its own — prompt_toolkit lays out one
codepoint at a time, so an uncorrected screen puts the male sign in a cell of its own and
everything after it a column late. The control merges the run, so the window makes a
single two-cell ``Char`` of it and the text after lands where the terminal draws it. The
codepoints themselves are untouched: all four still reach the screen, in order, so the
terminal composes the same glyph.
"""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False)
from prompt_toolkit.application import Application
from prompt_toolkit.application.current import set_app
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.input import DummyInput
from prompt_toolkit.layout import Layout, Window
from prompt_toolkit.layout.mouse_handlers import MouseHandlers
from prompt_toolkit.layout.screen import Screen, WritePosition
from prompt_toolkit.output import DummyOutput
text = f"|{_SHRUG}|{_FAMILY}|end"
def paint() -> list:
window = Window(ew.ClusterTextControl(lambda: ANSI(text)), always_hide_cursor=True)
app = Application(layout=Layout(window), input=DummyInput(), output=DummyOutput())
with set_app(app):
screen = Screen(default_char=None, initial_width=40, initial_height=1)
window.write_to_screen(
screen, MouseHandlers(), WritePosition(0, 0, 40, 1), "", False, None
)
return screen.data_buffer[0]
snap = _snapshot()
try:
ew._CALIBRATED = False
ew.calibrate(force_width=1)
row = paint()
# Each sequence is one cell-occupying Char, two cells wide, exactly as drawn.
assert (row[1].char, row[1].width) == (_SHRUG, 2)
assert (row[4].char, row[4].width) == (_FAMILY, 2)
# So the text after them starts at column 6 rather than 13 — seven cells of border
# drift removed (one for the shrug, four for the family, two from the second bar).
assert "".join(row[x].char for x in range(6, 10)) == "|end"
# Nothing was dropped on the way: the row still spells the source exactly.
assert "".join(row[x].char for x in range(40)).rstrip() == text
finally:
_restore(snap)