diff options
Diffstat (limited to 'contrib/python/wcwidth/py3/tests')
| -rw-r--r-- | contrib/python/wcwidth/py3/tests/conftest.py | 21 | ||||
| -rw-r--r-- | contrib/python/wcwidth/py3/tests/test_benchmarks.py | 86 | ||||
| -rw-r--r-- | contrib/python/wcwidth/py3/tests/test_clip.py | 4 | ||||
| -rw-r--r-- | contrib/python/wcwidth/py3/tests/test_core.py | 65 | ||||
| -rw-r--r-- | contrib/python/wcwidth/py3/tests/test_term_overrides.py | 597 | ||||
| -rw-r--r-- | contrib/python/wcwidth/py3/tests/test_text_sizing.py | 3 | ||||
| -rw-r--r-- | contrib/python/wcwidth/py3/tests/test_width.py | 6 |
7 files changed, 766 insertions, 16 deletions
diff --git a/contrib/python/wcwidth/py3/tests/conftest.py b/contrib/python/wcwidth/py3/tests/conftest.py index ecbbdc876e0..dbaaed8faa5 100644 --- a/contrib/python/wcwidth/py3/tests/conftest.py +++ b/contrib/python/wcwidth/py3/tests/conftest.py @@ -1,8 +1,29 @@ """Pytest configuration and fixtures.""" +# std imports +import os + # 3rd party import pytest +# local +from wcwidth._constants import resolve_terminal + + [email protected](autouse=True) +def _clear_resolve_terminal_cache(): + """Clear resolve_terminal cache and unset TERM/TERM_PROGRAM before each test.""" + saved_term = os.environ.pop('TERM', None) + saved_tprog = os.environ.pop('TERM_PROGRAM', None) + resolve_terminal.cache_clear() + yield + resolve_terminal.cache_clear() + if saved_term is not None: + os.environ['TERM'] = saved_term + if saved_tprog is not None: + os.environ['TERM_PROGRAM'] = saved_tprog + + try: # 3rd party from pytest_codspeed import BenchmarkFixture # noqa: F401 pylint:disable=unused-import diff --git a/contrib/python/wcwidth/py3/tests/test_benchmarks.py b/contrib/python/wcwidth/py3/tests/test_benchmarks.py index 80c9be01de6..7afcb2c0a5c 100644 --- a/contrib/python/wcwidth/py3/tests/test_benchmarks.py +++ b/contrib/python/wcwidth/py3/tests/test_benchmarks.py @@ -10,8 +10,9 @@ import pytest # local import wcwidth - -_width_module = sys.modules['wcwidth._width'] +# Access via import to trigger lazy-load on Python 3.15+; on older +# Python _width is already in sys.modules and this is a no-op. +import wcwidth._width as _width_module # noqa: E402 def test_wcwidth_ascii(benchmark): @@ -605,3 +606,84 @@ def test_ljust_udhr_lines(benchmark): benchmark.pedantic(lambda: [wcwidth.ljust(line, w + 1, UDHR_FILLCHAR) for line, w in zip(UDHR_LINES, UDHR_WIDTHS)], rounds=1, iterations=1) + + +_TERM_PROGRAMS = [ + 'ghostty', + 'xterm.js', +] + + [email protected]('term_program', _TERM_PROGRAMS) +def test_wcstwidth_term_program(benchmark, term_program): + """Benchmark wcstwidth() with term_program (ghostty=0 overrides vs xterm.js=237).""" + text = 'Hello δΈη π cafΓ© ' * 20 + benchmark(wcwidth.wcstwidth, text, term_program=term_program) + + [email protected]('term_program', _TERM_PROGRAMS) +def test_wcstwidth_ri_term_program(benchmark, term_program): + """Benchmark wcstwidth() with RI flags and term_program.""" + benchmark(wcwidth.wcstwidth, RI_FLAGS_PAIRED, term_program=term_program) + + [email protected]('term_program', _TERM_PROGRAMS) +def test_wcstwidth_emoji_term_program(benchmark, term_program): + """Benchmark wcstwidth() with emoji ZWJ sequences and term_program.""" + text = 'π¨\u200dπ©\u200dπ§\u200dπ¦' * 20 + benchmark(wcwidth.wcstwidth, text, term_program=term_program) + + [email protected]('term_program', _TERM_PROGRAMS) +def test_wcstwidth_wide_term_program(benchmark, term_program): + """Benchmark wcstwidth() with wide CJK and term_program.""" + text = 'γ³γ³γγγγγ»γ«γ€οΌ' * 20 + benchmark(wcwidth.wcstwidth, text, term_program=term_program) + + [email protected]('term_program', _TERM_PROGRAMS) +def test_width_term_program(benchmark, term_program): + """Benchmark width() with term_program (ghostty=0 overrides vs xterm.js=237).""" + text = 'Hello δΈη π cafΓ© ' * 20 + benchmark(wcwidth.width, text, term_program=term_program) + + [email protected]('term_program', _TERM_PROGRAMS) +def test_width_ri_term_program(benchmark, term_program): + """Benchmark width() with RI flags and term_program.""" + benchmark(wcwidth.width, RI_FLAGS_PAIRED, term_program=term_program) + + +# VS16/VS15-heavy text to exercise the vs16_narrower/vs15_wider bisearch paths +_VS16_TEXT = ('\u263A\uFE0F' # WHITE SMILING FACE + VS16 + '\u2764\uFE0F' # HEAVY BLACK HEART + VS16 + '\u2600\uFE0F' # BLACK SUN WITH RAYS + VS16 + '\u2615\uFE0F') * 25 # HOT BEVERAGE + VS16 + +_VS15_TEXT = ('\u2615\uFE0E' # HOT BEVERAGE + VS15 (wide=2 narrows to 1) + '\u231A\uFE0E' # WATCH + VS15 (wide=2 narrows to 1) + '\u23F0\uFE0E') * 34 # ALARM CLOCK + VS15 (wide=2 narrows to 1) + + [email protected]('term_program', _TERM_PROGRAMS) +def test_wcstwidth_vs16_term_program(benchmark, term_program): + """Benchmark wcswidth() with VS16 sequences to exercise vs16_narrower bisearch.""" + benchmark(wcwidth.wcstwidth, _VS16_TEXT, term_program=term_program) + + [email protected]('term_program', _TERM_PROGRAMS) +def test_wcstwidth_vs15_term_program(benchmark, term_program): + """Benchmark wcswidth() with VS15 sequences to exercise vs15_wider bisearch.""" + benchmark(wcwidth.wcstwidth, _VS15_TEXT, term_program=term_program) + + [email protected]('term_program', _TERM_PROGRAMS) +def test_width_vs16_term_program(benchmark, term_program): + """Benchmark width() with VS16 sequences to exercise vs16_narrower bisearch.""" + benchmark(wcwidth.width, _VS16_TEXT, term_program=term_program) + + [email protected]('term_program', _TERM_PROGRAMS) +def test_width_vs15_term_program(benchmark, term_program): + """Benchmark width() with VS15 sequences to exercise vs15_wider bisearch.""" + benchmark(wcwidth.width, _VS15_TEXT, term_program=term_program) diff --git a/contrib/python/wcwidth/py3/tests/test_clip.py b/contrib/python/wcwidth/py3/tests/test_clip.py index 8ab3f1d24e8..dee5b8c11cb 100644 --- a/contrib/python/wcwidth/py3/tests/test_clip.py +++ b/contrib/python/wcwidth/py3/tests/test_clip.py @@ -389,7 +389,7 @@ def test_clip_tab_first_visible_with_sgr(): def test_clip_overtyping_override_by_control_codes_ignore(): """When overtyping=True and control_codes='ignore', overtyping is overridden to False.""" - # elif entered: overtyping=True + control_codes='ignore' β overtyping=False + # elif entered: overtyping=True + control_codes='ignore': overtyping becomes False assert clip('hello world', 0, 5, overtyping=True, control_codes='ignore') == 'hello' # Verify that overtyping is actually disabled: cursor movement chars are # treated as zero-width, so the result is the same as without overtyping. @@ -398,7 +398,7 @@ def test_clip_overtyping_override_by_control_codes_ignore(): def test_clip_overtyping_without_ignore(): """When overtyping=True and control_codes='parse', elif is not entered.""" - # elif skipped: overtyping=True + control_codes='parse' β overtyping stays True + # elif skipped: overtyping=True + control_codes='parse': overtyping stays True # The painter path is used, cursor movement sequences affect output. assert clip('ab\x1b[2Dcd', 0, 4, overtyping=True, control_codes='parse') == 'cd' diff --git a/contrib/python/wcwidth/py3/tests/test_core.py b/contrib/python/wcwidth/py3/tests/test_core.py index dd1e3b7d1b7..9d5007571b1 100644 --- a/contrib/python/wcwidth/py3/tests/test_core.py +++ b/contrib/python/wcwidth/py3/tests/test_core.py @@ -199,9 +199,9 @@ def test_balinese_script(): phrase = ("\u1B13" # Category 'Lo', EAW 'N' -- BALINESE LETTER KA "\u1B28" # Category 'Lo', EAW 'N' -- BALINESE LETTER PA KAPAL "\u1B2E" # Category 'Lo', EAW 'N' -- BALINESE LETTER LA - "\u1B44") # Category 'Mc', EAW 'N' -- BALINESE ADEG ADEG + "\u1B44") # Category 'Mc', EAW 'N' -- BALINESE ADEG ADEG (virama) expect_length_each = (1, 1, 1, 0) - expect_length_phrase = 4 + expect_length_phrase = 3 # exercise, length_each = tuple(map(wcwidth.wcwidth, phrase)) @@ -308,7 +308,7 @@ def test_devanagari_script(): "\u093F") # MatraL, Category 'Mc', East Asian Width property 'N' -- DEVANAGARI VOWEL SIGN I # 23107-terminal-suppt.pdf suggests wcwidth.wcwidth should return (2, 0, 0, 1) expect_length_each = (1, 0, 1, 0) - # virama conjunct collapses KA+virama+SSA into one cell, Mc adds +1 + # grapheme cluster capped at 2 cells (ghostty, foot, Windows Terminal) expect_length_phrase = 2 # exercise, @@ -330,7 +330,7 @@ def test_tamil_script(): # 23107-terminal-suppt.pdf suggests wcwidth.wcwidth should return (3, 0, 0, 4) expect_length_each = (1, 0, 1, 0) - # virama conjunct collapses KA+virama+SSA into one cell, Mc adds +1 + # grapheme cluster capped at 2 cells (ghostty, foot, Windows Terminal) expect_length_phrase = 2 # exercise, @@ -353,7 +353,7 @@ def test_kannada_script(): "\u0cc8") # MatraUR, Category 'Mc', East Asian Width property 'N' -- KANNADA VOWEL SIGN AI # 23107-terminal-suppt.pdf suggests should be (2, 0, 3, 1) expect_length_each = (1, 0, 1, 0) - # virama conjunct collapses RA+virama+JHA into one cell, Mc adds +1 + # grapheme cluster capped at 2 cells (ghostty, foot, Windows Terminal) expect_length_phrase = 2 # exercise, @@ -454,11 +454,55 @@ def test_virama_conjunct(phrase, expected): assert wcwidth.width(phrase) == expected [email protected]("phrase,expected", [ + ("\u0995\u09CD\u09A4\u09BF", 2), # Bengali C+V+C+Mc: ΰ¦ΰ§ΰ¦€ΰ¦Ώ (capped at 2) + ("\u0915\u094D\u0924\u093F", 2), # Devanagari C+V+C+Mc: ΰ€ΰ₯ΰ€€ΰ€Ώ (capped at 2) + ("\u0995\u09CD\u09A4", 2), # C+V+C (no Mc), unchanged + ("\u0995\u09BF", 2), # C+Mc (no virama), unchanged +]) +def test_virama_conjunct_mc_vowel(phrase, expected): + """Mc combines into base; cluster capped at 2.""" + assert wcwidth.wcswidth(phrase) == expected + assert wcwidth.width(phrase) == expected + assert wcwidth.wcswidth(phrase) == expected + assert wcwidth.width(phrase) == expected + + [email protected]("phrase,expected", [ + ("\uA9A0\uA9C0\uA9B1\uA9C0\uA9AE", 2), # Javanese C+V+C+V+C: TA+PANGKON+SA+PANGKON+WA + ("\uA9A0\uA9C0\uA9B1", 2), # Javanese C+V+C: TA+PANGKON+SA + ("\u1B04\u1B44\u1B05", 2), # Balinese C+V+C: A+ADEG ADEG+I + ("\U000111C0\U000111C0", 0), # Sharada virama alone (zero-width) +]) +def test_virama_mc_category_overlap(phrase, expected): + """Virama codepoints in Mc category check ISC before Mc.""" + assert wcwidth.wcswidth(phrase) == expected + assert wcwidth.width(phrase) == expected + + [email protected]("phrase,expected", [ + ("\u1000\u1039\u1000", 2), # Burmese KA+VIRAMA+KA + ("\u1000\u1039\u1000\u1039\u1002", 2), # Burmese KA+V+KA+V+GA (capped) + ("\u1000\u1039\u200D\u1000", 2), # Burmese KA+V+ZWJ+KA + ("\u1782\u17D2\u1782\u17C1", 2), # Khmer KO+COENG+KO+VOWEL_E (capped) + ("\u1780\u17D2\u1780", 2), # Khmer KA+COENG+KA +]) +def test_virama_conjunct_invisible_stacker(phrase, expected): + assert wcwidth.wcswidth(phrase) == expected + assert wcwidth.width(phrase) == expected + + def test_zwj_at_end_of_string(): """ZWJ at end of string (not after virama) is consumed with zero width.""" assert wcwidth.wcswidth('a\u200D') == 1 +def test_wcswidth_n_exceeds_length(): + """Verify wcswidth() with n > len(string) does not raise IndexError.""" + assert wcwidth.wcswidth('hello', n=999) == 5 + assert wcwidth.wcswidth('\u30B3\u30F3', n=999) == 4 + + def test_soft_hyphen(): # Test SOFT HYPHEN, category 'Cf' usually are zero-width, but most # implementations agree to draw it was '1' cell, visually @@ -493,12 +537,11 @@ def test_prepended_concatenation_mark_width(codepoint, name): def test_legacy_module(): """Verify legacy ``wcwidth.wcwidth`` module's public items are importable.""" # pylint: disable=import-outside-toplevel - # std imports - import sys - - # Access the legacy submodule via sys.modules (matching 0.6.0 where - # 'import wcwidth.wcwidth' returned the function, not the module). - _legacy = sys.modules['wcwidth.wcwidth'] + # Save and restore wcwidth.wcwidth because importing the submodule + # rebinds the package attribute from the function to the module. + _wcwidth_func = wcwidth.wcwidth + _legacy = __import__('wcwidth.wcwidth', fromlist=['wcwidth']) + wcwidth.wcwidth = _wcwidth_func for name in _legacy.__all__: attr = getattr(_legacy, name) diff --git a/contrib/python/wcwidth/py3/tests/test_term_overrides.py b/contrib/python/wcwidth/py3/tests/test_term_overrides.py new file mode 100644 index 00000000000..0a7162ef800 --- /dev/null +++ b/contrib/python/wcwidth/py3/tests/test_term_overrides.py @@ -0,0 +1,597 @@ +"""Tests for terminal-specific width overrides.""" +# std imports +import os + +# 3rd party +import pytest + +# local +import wcwidth +import wcwidth.table_grapheme_overrides as grapheme_overrides +from wcwidth._constants import (_EMPTY_OVERRIDES, + _merge_ranges, + resolve_terminal, + get_term_overrides, + list_term_programs) +from wcwidth.table_overrides import VS15_OVERRIDES + + +def test_resolve_terminal_aliases(): + """resolve_terminal maps known aliases to canonical names.""" + assert resolve_terminal('kitty') == 'kitty' + assert resolve_terminal('vscode') == 'xterm.js' + assert resolve_terminal('urxvt') == 'urxvt' + + +def test_resolve_terminal_unknown(): + """resolve_terminal returns None for unrecognized names and empty string.""" + assert resolve_terminal('nonexistent') is None + assert resolve_terminal('') is None + + +def test_resolve_terminal_auto_detect(): + """resolve_terminal=True reads TERM_PROGRAM env var, falling back to TERM.""" + saved_tprog = os.environ.get('TERM_PROGRAM') + saved_term = os.environ.get('TERM') + try: + for var in ('TERM_PROGRAM', 'TERM'): + os.environ.pop(var, None) + resolve_terminal.cache_clear() + assert resolve_terminal(True) is None + os.environ['TERM_PROGRAM'] = 'kitty' + resolve_terminal.cache_clear() + assert resolve_terminal(True) == 'kitty' + finally: + for var, saved in (('TERM_PROGRAM', saved_tprog), ('TERM', saved_term)): + if saved is not None: + os.environ[var] = saved + else: + os.environ.pop(var, None) + resolve_terminal.cache_clear() + + +def test_wcswidth_no_override(): + """Wcswidth works normally without term_program or with empty string.""" + assert wcwidth.wcswidth('hello') == 5 + assert wcwidth.wcstwidth('hello', term_program='') == 5 + + [email protected]('char,expected_default,expected_vte', [ + ('\u2630', 2, 1), + ('\U0001f1e6', 2, 1), +]) +def test_wcswidth_vte_override(char, expected_default, expected_vte): + """VTE override narrows wide characters.""" + assert wcwidth.wcswidth(char) == expected_default + assert wcwidth.wcstwidth(char, term_program='VTE') == expected_vte + + [email protected]('text,kwargs,expected', [ + ('\u2630', {'term_program': 'VTE'}, 1), + ('\u2630', {'term_program': 'kitty'}, 2), + ('\x1b[31m\u2630\u2631\x1b[0m', {'term_program': 'VTE'}, 2), + ('\u2630\u2631', {'control_codes': 'ignore', 'term_program': 'VTE'}, 2), +]) +def test_width_vte_override(text, kwargs, expected): + """Width() applies VTE overrides with and without control codes.""" + assert wcwidth.width(text, **kwargs) == expected + + +def test_vs16_override_basic(): + """VS16 override is applied to heart emoji variation.""" + heart_vs16 = '\u2764\ufe0f' + assert wcwidth.wcswidth(heart_vs16) == 2 + assert wcwidth.wcstwidth(heart_vs16, term_program='VTE') == 1 + assert wcwidth.width(heart_vs16, term_program='VTE') == 1 + + +def test_vs16_libvterm_no_override(): + """Libvterm is not a known terminal; falls back to spec VS16 (returns 2).""" + assert wcwidth.wcstwidth('\u23ed\ufe0f', term_program='libvterm') == 2 + assert wcwidth.width('\u23ed\ufe0f', term_program='libvterm') == 2 + + +def test_wcwidth_unchanged(): + """Wcwidth() does not accept term_program parameter.""" + assert wcwidth.wcwidth('\u2630') == 2 + with pytest.raises(TypeError): + wcwidth.wcwidth('\u2630', term_program='VTE') # type: ignore[call-arg] + + +def test_wcstwidth_term_program(): + """Empty term_program disables override lookup.""" + assert wcwidth.wcstwidth('\u2630', term_program='') == 2 + assert wcwidth.wcstwidth('\u2630', term_program='VTE') == 1 + assert wcwidth.wcswidth('\u2630') == 2 + assert wcwidth.width('\u2630') == 2 + + +def test_wcswidth_ascii_unchanged(): + """ASCII text is unaffected by terminal overrides.""" + assert wcwidth.wcstwidth('hello world', term_program='VTE') == 11 + assert wcwidth.wcstwidth('hello world', term_program='kitty') == 11 + + +def test_vs15_standalone(): + """VS15 (U+FE0E) alone measures as width 0.""" + assert wcwidth.wcswidth('\ufe0e') == 0 + assert wcwidth.wcstwidth('\ufe0e', term_program='VTE') == 0 + + +def test_vs15_no_override(): + """VS15 after a character not in any override table has no effect.""" + base = '\u2630' + assert wcwidth.wcswidth(base + '\ufe0e') == wcwidth.wcswidth(base) + assert wcwidth.wcstwidth(base + '\ufe0e', term_program='kitty') == wcwidth.wcstwidth(base) + + +def test_vs15_wider_override_unchanged(): + """VS15 narrows by default; VTE wider override restores width 2.""" + assert wcwidth.wcswidth('\u231a') == 2 + assert wcwidth.wcswidth('\u231a\ufe0e') == 1 + assert wcwidth.wcstwidth('\u231a\ufe0e', term_program='VTE') == 2 + assert wcwidth.width('\u231a\ufe0e') == 1 + assert wcwidth.width('\u231a\ufe0e', term_program='VTE') == 2 + + +def test_grapheme_override_zwj_not_in_table(): + """ZWJ cluster not in override table falls through without error.""" + assert wcwidth.wcstwidth('π\u200dπ', term_program='VTE') == 2 + assert wcwidth.width('π\u200dπ', term_program='VTE') == 2 + + +def test_width_vs16_zwj_transition(): + """Width() VS16-applied state transitions to ZWJ_BLOCKED.""" + # smiley gets VS16'd (base_state=VS16_APPLIED), then ZWJ_BLOCKED + assert wcwidth.width('\u263a\ufe0f\u200da') == 2 + + +def test_width_vs15_override(): + """Width() with VS15 and terminal override.""" + assert wcwidth.width('\u231a\ufe0e', term_program='VTE') == 2 + assert wcwidth.width('\u2630\ufe0e', term_program='VTE') == 1 + + [email protected]('term_program,expected', [ + (False, 2), + ('', 2), + ('nonexistent', 2), + ('alacritty', 4), +]) +def test_grapheme_override_wcswidth_family(term_program, expected): + """Wcswidth ZWJ grapheme override applied only for recognized terminals with overrides.""" + family = '\U0001F468\u200D\U0001F466' + assert wcwidth.wcstwidth(family, term_program=term_program) == expected + + +def test_grapheme_override_multi_zwj_alacritty(): + """Wcswidth handles multi-ZWJ grapheme override.""" + family4 = '\U0001F468\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466' + default = wcwidth.wcswidth(family4) + override = wcwidth.wcstwidth(family4, term_program='alacritty') + assert default == 2 + assert override == 8 + + [email protected]('func,kwargs', [ + (wcwidth.width, {'term_program': 'alacritty'}), + (wcwidth.width, {'control_codes': 'ignore', 'term_program': 'alacritty'}), +]) +def test_grapheme_override_width_alacritty(func, kwargs): + """Width() applies ZWJ grapheme override for alacritty.""" + family = '\U0001F468\u200D\U0001F466' + assert func(family, **kwargs) == 4 + + +def test_grapheme_override_ascii_unchanged(): + """ASCII text is unaffected by grapheme overrides.""" + assert wcwidth.wcstwidth('hello', term_program='alacritty') == 5 + assert wcwidth.width('hello', term_program='alacritty') == 5 + + +def test_grapheme_override_zwj_at_end(): + """ZWJ at end of string does not trigger override scan.""" + text = '\U0001F468\u200D' + assert wcwidth.wcstwidth(text, term_program='alacritty') == 2 + + +def test_grapheme_override_fitzpatrick(): + """Fitzpatrick modifier between base and ZWJ handled correctly.""" + text = '\u26F9\U0001F3FB\u200D\u2640\uFE0F' + assert wcwidth.wcstwidth(text, term_program='alacritty') == 4 + + +def test_list_term_programs(): + """list_term_programs returns known terminals.""" + terms = list_term_programs() + assert isinstance(terms, tuple) + assert 'alacritty' in terms + assert 'vte' in terms + assert 'xterm.js' in terms + assert 'nonexistent' not in terms + + +def test_grapheme_override_invalid_term_names(): + """Grapheme override get() returns empty dict for invalid names.""" + assert grapheme_overrides.get(None) == {} + assert grapheme_overrides.get('__init__') == {} + assert grapheme_overrides.get('') == {} + assert grapheme_overrides.get('../../etc') == {} + + +def test_grapheme_override_zwj_no_extpict_base(): + """ZWJ after non-ExtPict base does not trigger override scan.""" + text = 'a\u200D\u200D' + assert wcwidth.wcstwidth(text, term_program='alacritty') == 1 + + [email protected]('text,term,expected', [ + ('π¨\u200dπ¦x', 'alacritty', 5), + ('π¨\u200da', 'alacritty', 2), + ('π¨\u200da', False, 2), +]) +def test_grapheme_override_scanner_edges(text, term, expected): + """Scanner edge cases for ZWJ chains.""" + assert wcwidth.wcstwidth(text, term_program=term) == expected + + +def test_grapheme_override_missing_module(): + """ + Returns None when registry hash points to missing _known_ module. + + This can occur during a program re-install when the registry and _known_* files are out of sync + (filesystem vs. in-memory copy differ). The ImportError is caught so measurement can continue + gracefully without per-terminal grapheme overrides. + """ + saved = grapheme_overrides._REGISTRY.get('putty') + try: + grapheme_overrides._REGISTRY['putty'] = 'deadbeef' + grapheme_overrides.get.cache_clear() + assert grapheme_overrides.get('putty') == {} + finally: + grapheme_overrides._REGISTRY['putty'] = saved + grapheme_overrides.get.cache_clear() + + +def test_no_terminal_has_vs15_narrower_overrides(): + """No terminal narrows VS15.""" + + # VS15 (text presentation) narrows a wide character to width 1. There is no width below 1 ! + narrower_terminals = { + term: data['narrower'] + for term, data in VS15_OVERRIDES.items() + if data.get('narrower') + } + assert not narrower_terminals, ( + f'Unexpected: terminal(s) with VS15 narrower overrides detected: ' + f'{sorted(narrower_terminals)}.\n' + f'VS15 cannot narrow a character below width 1. ' + f'This may indicate a ucs-detect measurement error or an unexpected terminal behavior.' + ) + + +def test_list_term_programs_includes_xterm(): + """Xterm is a recognized terminal program for explicit use.""" + assert 'xterm' in list_term_programs() + + +def test_resolve_terminal_xterm_explicit(): + """resolve_terminal returns 'xterm' when passed explicitly.""" + assert resolve_terminal('xterm') == 'xterm' + + [email protected]('env_var', ['TERM', 'TERM_PROGRAM']) +def test_resolve_terminal_xterm_auto_detected(env_var): + """resolve_terminal returns 'xterm' for xterm via auto-detection from env.""" + os.environ[env_var] = 'xterm' + resolve_terminal.cache_clear() + assert resolve_terminal(True) == 'xterm' + + [email protected]('func,text,expected_default,expected_xterm', [ + (wcwidth.wcstwidth, '\U0001f1e6', 2, 1), + (wcwidth.width, '\U0001f1e6', 2, 1), + (wcwidth.wcstwidth, '\u231a\ufe0e', 1, 2), + (wcwidth.width, '\u231a\ufe0e', 1, 2), +]) +def test_xterm_overrides_applied(func, text, expected_default, expected_xterm): + """Xterm overrides are applied when term_program='xterm' is explicit.""" + assert func(text) == expected_default + assert func(text, term_program='xterm') == expected_xterm + + [email protected]('func', [wcwidth.wcswidth, wcwidth.width]) +def test_zwj_fallthrough_resets_base_for_vs16(func): + """VS16 after ZWJ-skipped char does not connect to stale base (before fix, VS16 narrowed the + watch).""" + assert func('\u231a\u200d\u23f0\ufe0f') == 2 + + [email protected]('func', [wcwidth.wcswidth, wcwidth.width]) +def test_zwj_fallthrough_resets_base_for_vs15(func): + """VS15 after ZWJ-skipped char does not connect to stale base (before fix, VS15 narrowed the + watch).""" + assert func('\u231a\u200d\u23f0\ufe0e') == 2 + + [email protected]('func,text,dest_width,expected', [ + (wcwidth.ljust, '\u2630', 4, '\u2630 '), + (wcwidth.rjust, '\u2630', 4, ' \u2630'), + (wcwidth.center, '\u2630', 5, ' \u2630 '), +]) +def test_align_term_program_vte(func, text, dest_width, expected): + """Ljust/rjust/center pass term_program through to width().""" + assert func(text, dest_width, term_program='VTE') == expected + + +def test_clip_term_program_vte(): + """Clip() passes term_program through to width().""" + result = wcwidth.clip('\u2630\u2631', 0, 1, term_program='VTE') + assert result == '\u2630' + + +def test_wrap_term_program_vte(): + """Wrap() passes term_program through to width().""" + result = wcwidth.wrap('\u2630\u2631', width=2, term_program='VTE') + assert result == ['\u2630\u2631'] + + [email protected]('termenv,expected', [ + ({'TERM': 'xterm-kitty'}, 'kitty'), + ({'TERM_PROGRAM': '', 'TERM': 'xterm-kitty'}, 'kitty'), +]) +def test_resolve_terminal_from_env(termenv, expected): + """resolve_terminal reads TERM when TERM_PROGRAM is unset or empty.""" + for var in ('TERM_PROGRAM', 'TERM'): + os.environ.pop(var, None) + os.environ.update(termenv) + resolve_terminal.cache_clear() + assert resolve_terminal(True) == expected + + [email protected]('args,expected', [ + ((), ()), + ((((1, 5),),), ((1, 5),)), + ((((1, 3),), ((6, 8),)), ((1, 3), (6, 8))), + ((((1, 5),), ((4, 8),)), ((1, 8),)), +]) +def test_merge_ranges(args, expected): + """_merge_ranges merges sorted range tuples.""" + assert _merge_ranges(*args) == expected + + +def test_sfz_override_foot(): + """Foot narrows Fitzpatrick modifiers.""" + assert wcwidth.wcswidth('\U0001F3FB') == 2 + assert wcwidth.wcstwidth('\U0001F3FB', term_program='foot') == 1 + + [email protected]('term_program,expected', [ + ('kitty', 0), + ('bobcat', 0), + (False, 2), +]) +def test_sfz_zeroer(term_program, expected): + """Standalone Fitzpatrick modifiers zeroed per terminal.""" + assert wcwidth.wcswidth('\U0001F3FB') == 2 + assert wcwidth.wcstwidth('\U0001F3FB', term_program=term_program) == expected + + [email protected]('kwargs,expected', [ + ({}, 0), + ({'control_codes': 'ignore'}, 0), +]) +def test_width_zeroer(kwargs, expected): + """Width() zeroes standalone Fitzpatrick modifiers for kitty.""" + assert wcwidth.width('\U0001F3FB', term_program='kitty', **kwargs) == expected + + +def test_empty_overrides_includes_zeroer(): + """_EMPTY_OVERRIDES has six empty tuple fields.""" + assert _EMPTY_OVERRIDES.narrower == () + assert _EMPTY_OVERRIDES.vs16_narrower == () + assert _EMPTY_OVERRIDES.vs15_wider == () + assert _EMPTY_OVERRIDES.zeroer == () + assert _EMPTY_OVERRIDES.narrow_wider == () + assert _EMPTY_OVERRIDES.narrow_zeroer == () + + +def test_get_term_overrides_returns_empty_when_no_overrides(): + """get_term_overrides returns _EMPTY_OVERRIDES when terminal has no override data.""" + get_term_overrides.cache_clear() + overrides = get_term_overrides('no-such-terminal') + assert overrides is _EMPTY_OVERRIDES + + +def test_get_term_overrides_reads_narrow_zeroer_key(): + """get_term_overrides reads 'narrow_zeroer' key from NARROW_OVERRIDES.""" + get_term_overrides.cache_clear() + overrides = get_term_overrides('kitty') + assert len(overrides.narrow_zeroer) == 9 + assert overrides.narrow_zeroer[0] == (0x00AD, 0x00AD) + + +def test_get_term_overrides_narrow_wider_still_empty(): + """get_term_overrides narrow_wider is empty when no 'wider' entries exist.""" + get_term_overrides.cache_clear() + overrides = get_term_overrides('konsole') + assert overrides.narrow_wider == () + + [email protected]('func', [wcwidth.wcstwidth, wcwidth.width]) +def test_narrow_wider_width(func): + """Verify width() & wcstwidth() match result of narrow_wider overrides.""" + assert wcwidth.wcswidth('\u261d') == 1 + assert func('\u261d', term_program='kitty') == 2 + + [email protected]('codepoint', [ + '\u00ad', + '\u0600', + '\u0605', + '\u06dd', + '\u070f', + '\u0890', + '\u0891', + '\u08e2', + '\U000110bd', + '\U000110cd', +]) +def test_narrow_zeroer_cf_codepoints(codepoint): + """Cf format characters are zeroed by kitty/konsole/wezterm narrow_zeroer.""" + assert wcwidth.wcswidth(codepoint) == 1 + assert wcwidth.wcstwidth(codepoint, term_program='kitty') == 0 + assert wcwidth.wcstwidth(codepoint, term_program='konsole') == 0 + assert wcwidth.wcstwidth(codepoint, term_program='wezterm') == 0 + + [email protected]('func', [wcwidth.wcstwidth, wcwidth.width]) +def test_narrow_zeroer_not_applied_for_other_terminals(func): + """Terminals without narrow overrides keep width 1 for Cf characters.""" + assert func('\u0600', term_program='xterm') == 1 + assert func('\u0600', term_program='ghostty') == 1 + assert func('\u0600', term_program='') == 1 + + [email protected]('func,term_program,expected', [ + (wcwidth.wcstwidth, 'kitty', 0), + (wcwidth.width, 'kitty', 0), + (wcwidth.wcstwidth, 'konsole', 0), + (wcwidth.width, 'konsole', 0), + (wcwidth.wcstwidth, 'wezterm', 0), + (wcwidth.width, 'wezterm', 0), +]) +def test_narrow_zeroer_width(func, term_program, expected): + """Width() matches wcstwidth() for narrow_zeroer overrides.""" + assert func('\u0600', term_program=term_program) == expected + + [email protected]('value,expected', [ + (' KITTY ', 'kitty'), + (' ', None), +]) +def test_resolve_terminal_strips_whitespace(value, expected): + """resolve_terminal strips, lowercases, and returns None for whitespace-only.""" + assert resolve_terminal(value) == expected + + [email protected]('text,term_program,expected', [ + ('\u1000\u1031', False, 2), # MYANMAR LETTER KA + MYANMAR VOWEL SIGN E (Burmese) + ('\u1000\u1031', '', 2), + ('\u1000\u1031', 'kitty', 1), + ('\u1000\u1031', 'foot', 1), + ('\u1000\u1031', 'alacritty', 2), + ('\u0c05\u0c02', False, 2), # TELUGU LETTER A + TELUGU SIGN ANUSVARA + ('\u0c05\u0c02', 'kitty', 1), + ('\u0e01\u0e33', False, 2), # THAI CHARACTER KO KAI + THAI CHARACTER SARA AM + ('\u0e01\u0e33', 'kitty', 1), + ('\u0985\u0982', False, 2), # BENGALI LETTER A + BENGALI SIGN ANUSVARA + ('\u0985\u0982', 'kitty', 1), + ('\u0915\u093e', False, 2), # DEVANAGARI LETTER KA + DEVANAGARI VOWEL SIGN AA + ('\u0915\u093e', 'kitty', 1), + ('\u0915\u093e', 'foot', 1), + ('\u0915\u093e', 'alacritty', 2), +]) +def test_wcswidth_language_grapheme(text, term_program, expected): + """Language grapheme clusters use per-terminal override tables.""" + assert wcwidth.wcstwidth(text, term_program=term_program) == expected + + [email protected]('text,term_program,expected', [ + ('\u1000\u1031', 'kitty', 1), # MYANMAR LETTER KA + VOWEL SIGN E + ('\u1000\u1031', 'foot', 1), + ('\u0915\u093e', 'kitty', 1), # DEVANAGARI LETTER KA + VOWEL SIGN AA + ('\u0915\u093e', 'foot', 1), + ('\u0c05\u0c02', 'kitty', 1), # TELUGU LETTER A + SIGN ANUSVARA +]) +def test_width_language_grapheme(text, term_program, expected): + """Width() applies language grapheme overrides.""" + assert wcwidth.width(text, term_program=term_program) == expected + + [email protected]('text,term_program,expected', [ + ('hello', 'kitty', 5), + ('hello', 'foot', 5), + ('hello\u1000\u1031', 'kitty', 6), # ASCII + Burmese KA + VOWEL SIGN E + ('\u1000\u1031hello', 'kitty', 6), # Burmese KA + VOWEL SIGN E + ASCII + ('\u1000\u1031\u1000\u1031', 'kitty', 2), # two Burmese KA+E clusters + ('\u1000\u1031x\u0915\u093e', 'kitty', 3), # Burmese + ASCII + Devanagari +]) +def test_wcswidth_mixed_language_ascii(text, term_program, expected): + """Language grapheme overrides do not affect ASCII and mix correctly.""" + assert wcwidth.wcstwidth(text, term_program=term_program) == expected + + [email protected]('text,term_program,expected', [ + ('\u1000\u1039\u1001', False, 2), # MYANMAR KA + VIRAMA + KHA (conjunct) + ('\u1000\u1039\u1001', 'kitty', 1), + ('\u1000\u1039\u1001', 'foot', 2), + ('\u1000\u103b\u102d\u102f', False, 2), # MYANMAR KA + MEDIAL YA + VOWEL I + VOWEL U + ('\u1000\u103b\u102d\u102f', 'kitty', 1), + ('\u1000\u103b\u102d\u102f', 'foot', 1), +]) +def test_wcswidth_virama_conjunct(text, term_program, expected): + """Virama conjunct grapheme clusters use per-terminal overrides.""" + assert wcwidth.wcstwidth(text, term_program=term_program) == expected + + [email protected]('text,term_program,expected', [ + ('\u0915\u094D\u200D\u0937', False, 2), # Devanagari C+Virama+ZWJ+C (explicit ZWJ conjunct) + ('\u0915\u094D\u200D\u0937', 'xterm', 2), +]) +def test_wcswidth_virama_zwj_conjunct(text, term_program, expected): + """Virama+ZWJ conjunct skips ZWJ and forms a capped conjunct.""" + assert wcwidth.wcstwidth(text, term_program=term_program) == expected + + [email protected]('text,term_program,expected', [ + ('\u1000\u1031', 'xterm', 2), # Burmese: no xterm override + ('\u0915\u093e', 'xterm', 2), # Devanagari: no xterm override + ('\u0c05\u0c02', 'xterm', 2), # Telugu: no xterm override +]) +def test_wcswidth_language_no_override(text, term_program, expected): + """Terminals without language overrides return spec width.""" + assert wcwidth.wcstwidth(text, term_program=term_program) == expected + + [email protected]('text,term_program,expected', [ + ('\u1000\u1031X', 'kitty', 2), # Burmese C+Mc mid-string flush via cluster_text + ('X\u1000\u1031', 'kitty', 2), # ASCII then Burmese C+Mc append + ('\u1000\u1031\u1000\u1031', 'kitty', 2), # two Burmese C+Mc with flush between + ('\u0915\u093eX', 'kitty', 2), # Devanagari C+Mc mid-string flush +]) +def test_width_cluster_text_override_mid_string(text, term_program, expected): + """Width() flushes cluster via cluster_text override when followed by another char.""" + assert wcwidth.width(text, term_program=term_program) == expected + + [email protected]('text,term_program,expected', [ + ('\u0e01\u0e33X', 'kitty', 2), # Thai C+Lo override pair then ASCII + ('X\u0e01\u0e33', 'kitty', 2), # ASCII then Thai override pair + ('\u0e01\u0e33\u0e01\u0e33', 'kitty', 2), # two Thai override pairs +]) +def test_width_candidate_override_mid_string(text, term_program, expected): + """Width() flushes cluster via candidate override when two Lo chars form a pair.""" + assert wcwidth.width(text, term_program=term_program) == expected + + [email protected]('text,term_program,expected', [ + ('\r\u1000\u1031', 'xterm', 2), # CR reset, cluster extends beyond prior max_extent + ('\r\u0915\u093e', 'xterm', 2), # Devanagari same pattern + ('XX\b\b\u1000\u1031', 'xterm', 2), # backspace then cluster does not exceed prior max +]) +def test_width_epilogue_max_extent_update(text, term_program, expected): + """Width() updates max_extent in epilogue when final cluster extends beyond prior max.""" + assert wcwidth.width(text, term_program=term_program) == expected + + +def test_wcstwidth_ambiguous_width_2(): + """Wcstwidth respects ambiguous_width parameter.""" + assert wcwidth.wcstwidth('\u00b1', ambiguous_width=2, term_program='VTE') == 2 + assert wcwidth.wcstwidth('\u00b1', ambiguous_width=2, term_program='') == 2 + + +def test_wcstwidth_control_character(): + """Wcstwidth returns -1 for C0 control characters.""" + assert wcwidth.wcstwidth('hello\x01world', term_program='VTE') == -1 + assert wcwidth.wcstwidth('\x01', term_program='') == -1 diff --git a/contrib/python/wcwidth/py3/tests/test_text_sizing.py b/contrib/python/wcwidth/py3/tests/test_text_sizing.py index b5e18085e35..1566c3192d4 100644 --- a/contrib/python/wcwidth/py3/tests/test_text_sizing.py +++ b/contrib/python/wcwidth/py3/tests/test_text_sizing.py @@ -155,6 +155,7 @@ def test_text_sizing_sequence(given_sequence, expected_text, expected_params, ex ('\x1b]66;w=2;A\x07\x1b]66;w=3;B\x07', 5), ('\x1b]66;s=2:w=3;text\x1b\\', 6), ('\x1b[31m\x1b]66;w=2;AB\x07\x1b[0m', 2), + ('\x1b]66;;\x01\x07', 0), ]) def test_strings_with_text_sizing(text, expected): """Verify measured width strings containing OSC66.""" @@ -294,7 +295,7 @@ def test_clip_text_sizing_scaled_with_fillchar(text, start, end, expected): def test_clip_simple_path_padding(): """Simple-path clip with w=N larger than text length exercises padding loop.""" - # w=4 but only 1 grapheme 'X' β 3 empty units are padded. + # w=4 but only 1 grapheme 'X': 3 empty units are padded. # Clip window (0, 1) forces partial overlap, triggering # _text_sizing_clip_simple's padding branch. assert repr(clip('\x1b]66;w=4;X\x07', 0, 1)) == repr('\x1b]66;w=1;X\x07') diff --git a/contrib/python/wcwidth/py3/tests/test_width.py b/contrib/python/wcwidth/py3/tests/test_width.py index 8e43b47b1de..436ca5caf6e 100644 --- a/contrib/python/wcwidth/py3/tests/test_width.py +++ b/contrib/python/wcwidth/py3/tests/test_width.py @@ -170,6 +170,12 @@ EDGE_CASES = [ ('\x1b', 0, 'lone_ESC'), ('\x1b!', 1, 'ESC_unrecognized'), ('*\x1b*', 2, 'lone_ESC_between_text'), + ('\tA\u0CBE\rB\u0CBE\b', 10, 'backspace_flush_below_max_extent'), + ('\tA\u0CBE\rB\u0CBE\x1b', 10, 'esc_flush_below_max_extent'), + ('\tA\u0CBE\rB\u0CBE\x00', 10, 'zero_width_flush_below_max_extent'), + ('\tA\u0CBE\rB\u0CBE\x01', 10, 'illegal_ctrl_flush_below_max_extent'), + ('\tA\u0CBE\rB\u0CBE\n', 10, 'vertical_ctrl_flush_below_max_extent'), + ('\u4e2d\u0bcd\u4e2d\u0bcd\u4e2d\u0bcd\u4e2d', 2, 'glitch_virama_chain_capped'), ] |
