Files
MeshTerm/tests/test_emoji_width.py
T
Jean-Pierre MartineauandClaude Opus 5 282334c761 The purge preview shows the evidence in lanes of its own kind
The preview's rows led on a PCTL lane and followed it with WHY IT RANKS LOW, one
header over three lanes that held a contact's n-th weakest reason — so a message
count, an age, a packet tally and a hop count moved between columns from row to
row and nothing in a column meant the same thing twice. The lanes are fixed by
kind now: HEARD and PKTS first, in the Contacts list's own order and drawn in its
own grammar (the column age under recency heat, a tally muted, a faint dash for
none), then the two the sweep adds, MSGS and HOPS. Each is measured across the
victims and headed by a word right-aligned over its own values.

The percentile goes with the phrases. A rank is the score's reading of itself, so
a lane of them restated the order the list was already in; what an audit needs is
the measurements, which is what a reader can check against what they know. The
row pins its name out of the horizontal scroll instead — the evidence is what a
narrow terminal cuts, and the name is what says whose evidence it is. ScoredContact
drops the reasons it was the only consumer of.

A lane also stops trusting a name to be ordinary text. A name is written by whoever
owns the radio, so fit_cells — THE lane fitter, and every column's — now cuts
between glyphs rather than codepoints and folds what no terminal can draw: cutting
an emoji sequence in half stranded a joiner that swallowed the ellipsis appended
after it, leaving the lane a cell short of what was drawn and every column right of
the name starting late, and a newline or an escape in a name ended the row mid-lane
whatever it measured. Both are settled in emoji_width, which already owns what this
terminal really does with these codepoints.

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

412 lines
22 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.
# SPDX-License-Identifier: Apache-2.0
"""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
_THUMB = "\U0001f44d\U0001f3fd" # 👍🏽 a base and its skin tone, no joiner — still one glyph
_TECHIE = "\U0001f468\U0001f3fb‍\U0001f4bb" # 👨🏻‍💻 a toned base, then a joined laptop
_FLAG = "\U0001f3f4" # 🏴 a black flag, the base of the pirate flag below
_STRANDED = f"{_FLAG}‍" # 🏴‍☠️ cut after its joiner by a name's byte limit: joins nothing
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 ──")
# The Trophy case's *visible* gap after the road is not this module's business and must
# not become it: the heading pads the mark out to the widest of the seven disciplines
# (records_screen.discipline_label), which costs a real cell the terminal advances over
# — where widening the glyph here would claim one it does not.
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
assert get_cwidth(_TECHIE) == 2 # was 6: a skin tone is no cell, at either width
finally:
_restore(snap)
def test_a_skin_tone_is_part_of_its_glyph_in_both_authorities() -> None:
"""A skin-tone modifier recolours the emoji before it and takes no cell of its own.
wcwidth counts each modifier as two, so prompt_toolkit reserved four cells for ``👍🏽`` and
for the joined ``👨🏻‍💻`` — a two-cell notch in every row naming such a node, and every lane
after the name drawn two columns off. Rich's table already says zero; the cache now agrees,
on both calibration paths.
"""
for narrow, flags in ((frozenset(_WAVE), True), (frozenset(), False)):
cache = ew._make_pt_cache(narrow, frozenset(), flags=flags)
assert cache[_THUMB] == 2
assert cache[_TECHIE] == 2
assert cache[f"Tech {_TECHIE} x"] == len("Tech ") + 2 + len(" x")
assert ew._make_cell_len(frozenset(_WAVE), frozenset())(_TECHIE) == 2
def test_a_stranded_joiner_leaves_the_next_character_its_cell(monkeypatch) -> None:
"""A joiner with no pictograph after it joins nothing, in both authorities and both paths.
A MeshCore name is cut at a byte limit, so ``That's So Fetch 🏴‍☠️`` arrives as the flag and
a bare joiner. Both authorities folded whatever came next into the flag — the padding after
the name — so the name lane measured a cell short and every lane after it began a column
before the terminal drew it.
"""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
padded = f"{_STRANDED} x" # the flag's two cells, two of padding, then the next lane
cell_len = ew._make_cell_len(frozenset(_WAVE), frozenset())
assert cell_len(padded) == 5
assert ew._make_pt_cache(frozenset(_WAVE), frozenset())[padded] == 5
# A real sequence still joins: the shrug's male sign is a pictograph, not a padding space.
assert cell_len(f"{_SHRUG} x") == 4
snap = _snapshot()
try:
ew._CALIBRATED = False
ew.calibrate(force_width=2)
# Rich's own loop, trusted on this path, skipped the space and counted four.
assert cells.cell_len(padded) == 5
assert cells.cell_len(_SHRUG) == 2 and cells.cell_len(_FAMILY) == 2
finally:
_restore(snap)
def test_clusters_split_between_glyphs_and_never_inside_one() -> None:
"""The unit a lane may cut on: one entry per glyph the terminal actually draws.
A joined run, a flag's indicator *pair*, and a toned base each come back whole, because
each is one glyph — while a joiner that joins nothing stands on its own, as it does
everywhere else in this module (see :func:`~meshterm.ui.tui.emoji_width._joins`).
"""
assert list(ew.clusters(f"a{_FAMILY}{_CA}{_THUMB}b")) == ["a", _FAMILY, _CA, _THUMB, "b"]
# Two flags in a row pair up one at a time rather than running together into four.
assert list(ew.clusters(_CA + _CN)) == [_CA, _CN]
# A name cut at its byte limit just past a joiner: the joiner is not part of the flag.
assert list(ew.clusters(_STRANDED)) == [_FLAG, _ZWJ]
def test_cut_cells_keeps_every_glyph_whole_and_strands_no_joiner() -> None:
"""A lane's truncation lands between glyphs, so nothing measures what is not drawn.
The failure this exists for: cutting a codepoint at a time leaves a trailing joiner,
which folds whatever the caller appends — the lane's ellipsis — into the glyph before
it. The ellipsis then measures nothing while the terminal still draws it, and every
column right of the name starts a cell late.
"""
assert ew.cut_cells(f"Bob {_FAMILY}", 6) == f"Bob {_FAMILY}" # it fits, so it is kept
assert ew.cut_cells(f"Bob {_FAMILY}", 5) == "Bob " # it doesn't: dropped whole
assert ew.cut_cells(_CA, 1) == "" # half a flag is a letter, not half a glyph
assert ew.cut_cells(_STRANDED, 4) == _FLAG # the joiner cannot swallow what follows
assert ew.cut_cells("plain name", 5) == "plain"
def test_drawable_folds_only_what_no_terminal_can_draw() -> None:
"""A newline, an escape or a bidi override in an advert name is folded to a space.
None of them is a width bug an allowlist could fix — they are text that must not reach
the terminal at all. The joiner is format-class too and is the one kept, because it is
what holds an emoji sequence together.
"""
assert ew.drawable("line\nbreak") == "line break"
assert ew.drawable("esc\x1b[31mape") == "esc [31mape"
assert ew.drawable("flip‮me") == "flip me"
assert ew.drawable(f"Bob {_FAMILY}{_THUMB}") == f"Bob {_FAMILY}{_THUMB}"
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 skin tone on the base belongs to the run the way its selector does.
assert texts(ew._join_zwj_clusters(line(f"|{_TECHIE}|"))) == ["|", _TECHIE, "|"]
# A stranded joiner, followed by the name lane's padding, is not a sequence: the space
# after it keeps a fragment — and so a cell — of its own.
assert texts(ew._join_zwj_clusters(line(f"{_STRANDED} x"))) == [_FLAG, _ZWJ, " ", "x"]
# 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)