|
6 | 6 | import pytest |
7 | 7 |
|
8 | 8 | # local |
9 | | -from wcwidth import iter_graphemes |
| 9 | +from wcwidth import iter_graphemes, iter_graphemes_reverse, grapheme_boundary_before |
10 | 10 |
|
11 | 11 | try: |
12 | 12 | chr(0x2fffe) |
@@ -144,3 +144,112 @@ def test_wide_unicode_graphemes(input_str, expected): |
144 | 144 | def test_unicode_grapheme_break_test(input_str, expected): |
145 | 145 | """Validate against official Unicode GraphemeBreakTest.txt.""" |
146 | 146 | assert list(iter_graphemes(input_str)) == expected |
| 147 | + |
| 148 | + |
| 149 | +# Prepend: Arabic Number Sign |
| 150 | +PREPEND_CHAR = '\u0600' |
| 151 | +# Multiple combining marks: e + acute + grave |
| 152 | +MULTI_COMBINE = 'e\u0301\u0300' |
| 153 | + |
| 154 | + |
| 155 | +# grapheme_boundary_before(text, pos) returns start of grapheme cluster before pos. |
| 156 | +# (text, pos, expected): pos=search from here, expected=where cluster starts |
| 157 | +@pytest.mark.parametrize(("text", "pos", "expected"), [ |
| 158 | + # 'abc': 0=a, 1=b, 2=c |
| 159 | + ('abc', 3, 2), # from end -> 'c' at 2 |
| 160 | + ('abc', 2, 1), # from 'c' -> 'b' at 1 |
| 161 | + ('abc', 1, 0), # from 'b' -> 'a' at 0 |
| 162 | + # 'a\r\nb': CRLF is one cluster (GB3) |
| 163 | + ('a\r\nb', 3, 1), # from 'b' -> '\r\n' at 1 |
| 164 | + # 'café': e + combining acute is one cluster (GB9) |
| 165 | + ('cafe\u0301', 5, 3), # from end -> 'é' at 3 |
| 166 | + ('cafe\u0301', 4, 3), # from acute -> still 'é' at 3 |
| 167 | + # Multiple combining marks: e + acute + grave (GB9) |
| 168 | + ('a' + MULTI_COMBINE + 'b', 4, 1), # from 'b' -> e+marks at 1 |
| 169 | + # Prepend + char is one cluster (GB9b) |
| 170 | + (PREPEND_CHAR + 'a', 2, 0), # whole cluster |
| 171 | + # Prepend + Control: control breaks (GB4) |
| 172 | + (PREPEND_CHAR + '\n', 2, 1), # '\n' separate at 1 |
| 173 | + # C1 control (NEL, 0x85) stops backward scan in _find_cluster_start (GB4) |
| 174 | + ('X\x85\u0301', 3, 2), |
| 175 | +]) |
| 176 | +def test_grapheme_boundary_before_basic(text, pos, expected): |
| 177 | + """Basic grapheme_boundary_before tests.""" |
| 178 | + assert grapheme_boundary_before(text, pos) == expected |
| 179 | + |
| 180 | + |
| 181 | +@pytest.mark.skipif(NARROW_ONLY, reason="requires wide Unicode") |
| 182 | +@pytest.mark.parametrize(("text", "pos", "expected"), [ |
| 183 | + # 'Hi 👋🏻!': 0=H,1=i,2=space,3=wave,4=skin,5=!; wave+skin is one cluster |
| 184 | + ('Hi \U0001F44B\U0001F3FB!', 6, 5), # from end -> '!' at 5 |
| 185 | + ('Hi \U0001F44B\U0001F3FB!', 5, 3), # from '!' -> wave+skin at 3 |
| 186 | + ('Hi \U0001F44B\U0001F3FB!', 3, 2), # from wave -> space at 2 |
| 187 | + # 'a🇺🇸b': 0=a,1-2=flag,3=b; flag is one cluster (GB12/13) |
| 188 | + ('a' + FLAG_US + 'b', 4, 3), # from end -> 'b' at 3 |
| 189 | + ('a' + FLAG_US + 'b', 3, 1), # from 'b' -> flag at 1 |
| 190 | + # Three RIs (🇺🇸🇦): flag + solo RI |
| 191 | + (FLAG_US + RI_A, 3, 2), # from end -> solo RI at 2 |
| 192 | + (FLAG_US + RI_A, 2, 0), # from solo -> flag at 0 |
| 193 | + # 'a👨👩👧b': 0=a,1-5=family,6=b; ZWJ sequence is one cluster (GB11) |
| 194 | + ('a' + FAMILY + 'b', 7, 6), # from end -> 'b' at 6 |
| 195 | + ('a' + FAMILY + 'b', 6, 1), # from 'b' -> family at 1 |
| 196 | +]) |
| 197 | +def test_grapheme_boundary_before_unicode(text, pos, expected): |
| 198 | + """grapheme_boundary_before with emoji and wide Unicode.""" |
| 199 | + assert grapheme_boundary_before(text, pos) == expected |
| 200 | + |
| 201 | + |
| 202 | +@pytest.mark.parametrize(("input_str", "expected"), [ |
| 203 | + ('', []), |
| 204 | + ('abc', ['c', 'b', 'a']), |
| 205 | + # café with combining mark mixed with CRLF |
| 206 | + ('cafe\u0301\r\nok', ['k', 'o', '\r\n', 'e\u0301', 'f', 'a', 'c']), |
| 207 | +]) |
| 208 | +def test_iter_graphemes_reverse_basic(input_str, expected): |
| 209 | + """Basic iter_graphemes_reverse tests.""" |
| 210 | + assert list(iter_graphemes_reverse(input_str)) == expected |
| 211 | + |
| 212 | + |
| 213 | +@pytest.mark.skipif(NARROW_ONLY, reason="requires wide Unicode") |
| 214 | +@pytest.mark.parametrize(("input_str", "expected"), [ |
| 215 | + # Multiple emoji types in one string |
| 216 | + ('cafe\u0301 ' + WAVE_SKIN + ' ' + FLAG_US + '!', |
| 217 | + ['!', FLAG_US, ' ', WAVE_SKIN, ' ', 'e\u0301', 'f', 'a', 'c']), |
| 218 | + # Two families |
| 219 | + (FAMILY + FAMILY, [FAMILY, FAMILY]), |
| 220 | + # Flag + solo RI + text |
| 221 | + ('Hi' + FLAG_US + RI_A + '!', ['!', RI_A, FLAG_US, 'i', 'H']), |
| 222 | +]) |
| 223 | +def test_iter_graphemes_reverse_unicode(input_str, expected): |
| 224 | + """iter_graphemes_reverse with wide Unicode.""" |
| 225 | + assert list(iter_graphemes_reverse(input_str)) == expected |
| 226 | + |
| 227 | + |
| 228 | +@pytest.mark.skipif(NARROW_ONLY, reason="requires wide Unicode") |
| 229 | +@pytest.mark.parametrize(("input_str", "expected"), read_grapheme_break_test()) |
| 230 | +def test_grapheme_roundtrip_consistency(input_str, expected): |
| 231 | + """Forward and reverse iteration produce identical boundaries.""" |
| 232 | + forward = list(iter_graphemes(input_str)) |
| 233 | + reverse = list(iter_graphemes_reverse(input_str))[::-1] |
| 234 | + assert forward == reverse |
| 235 | + |
| 236 | + |
| 237 | +def test_grapheme_boundary_before_edge_cases(): |
| 238 | + """Edge cases for grapheme_boundary_before.""" |
| 239 | + assert grapheme_boundary_before('abc', 0) == 0 |
| 240 | + assert grapheme_boundary_before('abc', 100) == 2 # pos > len clamps |
| 241 | + assert grapheme_boundary_before('', 0) == 0 |
| 242 | + |
| 243 | + |
| 244 | +def test_iter_graphemes_reverse_edge_cases(): |
| 245 | + """Edge cases for iter_graphemes_reverse.""" |
| 246 | + assert list(iter_graphemes_reverse('abcdef', start=2, end=5)) == ['e', 'd', 'c'] |
| 247 | + assert list(iter_graphemes_reverse('abc', start=0, end=100)) == ['c', 'b', 'a'] |
| 248 | + assert not list(iter_graphemes_reverse('abc', start=5)) |
| 249 | + assert not list(iter_graphemes_reverse('abc', start=2, end=2)) |
| 250 | + # PREPEND + char is one grapheme (GB9b), so start=1 yields nothing (won't split) |
| 251 | + assert not list(iter_graphemes_reverse(PREPEND_CHAR + 'a', start=1)) |
| 252 | + # But start=0 yields the full grapheme |
| 253 | + assert list(iter_graphemes_reverse(PREPEND_CHAR + 'a', start=0)) == [PREPEND_CHAR + 'a'] |
| 254 | + # Negative start is clamped to 0 |
| 255 | + assert list(iter_graphemes_reverse('abc', start=-5)) == ['c', 'b', 'a'] |
0 commit comments