Skip to content

Commit 655070c

Browse files
authored
Merge pull request #1202 from PyThaiNLP/copilot/add-test-suite-for-pythainlp
Add comprehensive robustness tests for real-world edge cases
2 parents 0322811 + cfb4007 commit 655070c

2 files changed

Lines changed: 270 additions & 0 deletions

File tree

tests/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,20 @@ Tests are categorized into three groups: core, compact, and extra.
1111
- Test with all officially supported Python versions
1212
(currently 3.9, 3.10, 3.11, 3.12, and 3.13).
1313

14+
### Robustness Tests (test_robustness.py)
15+
16+
A comprehensive test suite within core tests that tests edge cases important
17+
for real-world usage:
18+
19+
- Empty strings and various whitespace handling (spaces, tabs, unicode spaces)
20+
- Special characters from encoding issues, BOM, terminal copy/paste
21+
- Truncated/malformed Unicode and surrogate pairs
22+
- Emoji and modern Unicode sequences (ZWJ, modifiers, flags)
23+
- Control and hidden/invisible characters (zero-width, control chars)
24+
- Thai-specific edge cases with combining characters and mixed scripts
25+
- Multi-engine robustness testing across all core tokenization engines
26+
- Very long strings that can cause performance issues (issue #893)
27+
1428
## Compact Tests (testc_*.py)
1529

1630
- Run `unittest tests.compact`

tests/core/test_robustness.py

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
# SPDX-FileCopyrightText: 2026 PyThaiNLP Project
2+
# SPDX-FileType: SOURCE
3+
# SPDX-License-Identifier: Apache-2.0
4+
"""
5+
Robustness tests for PyThaiNLP functions.
6+
7+
This test suite focuses on edge cases important for real-world usage:
8+
- Empty strings and various whitespace handling
9+
- Special characters from terminal copy/paste and encoding issues
10+
- Unicode edge cases (truncated, BOM, control characters)
11+
- Emoji and hidden/invisible characters
12+
- Multi-engine robustness testing for word_tokenize
13+
- Very long strings that can cause performance issues (issue #893)
14+
"""
15+
16+
import unittest
17+
18+
from pythainlp.tokenize import word_tokenize
19+
20+
21+
class RobustnessTestCase(unittest.TestCase):
22+
"""Test PyThaiNLP functions with edge cases."""
23+
24+
# Tokenization engines to test (core engines without external dependencies)
25+
TOKENIZE_ENGINES = ["newmm", "newmm-safe", "longest", "mm"]
26+
27+
# Category: Empty and Whitespace strings
28+
# Real-world cases from copy/paste, terminal input, etc.
29+
EMPTY_AND_WHITESPACE = [
30+
"", # Empty string
31+
" ", # Single space
32+
" ", # Multiple spaces
33+
"\t", # Tab
34+
"\n", # Newline
35+
"\r\n", # Windows line ending
36+
" \t\n\r", # Mixed whitespace
37+
"\u00a0", # Non-breaking space
38+
"\u2000\u2001\u2002", # Various unicode spaces
39+
"\u3000", # Ideographic space (CJK)
40+
]
41+
42+
# Category: Special characters from encoding issues and terminal
43+
# BOM, control characters, special punctuation
44+
SPECIAL_CHARS = [
45+
"\ufeff", # BOM (Byte Order Mark)
46+
"\ufffe", # BOM reversed
47+
"\x00", # Null character
48+
"\u0000", # Null unicode
49+
"‌‍", # Zero-width non-joiner, zero-width joiner
50+
"\u200c\u200d", # ZWNJ, ZWJ
51+
"\u200e\u200f", # LTR mark, RTL mark
52+
"­", # Soft hyphen
53+
"\u00ad", # Soft hyphen unicode
54+
"…", # Ellipsis
55+
"—–-", # Different dashes
56+
"\u201c\u201d", # Smart double quotes (curly quotes)
57+
"\u2018\u2019", # Smart single quotes (curly quotes)
58+
]
59+
60+
# Category: Truncated/malformed Unicode
61+
# Characters that might be cut in the middle of encoding
62+
TRUNCATED_UNICODE = [
63+
"\ud800", # High surrogate alone (invalid)
64+
"\udc00", # Low surrogate alone (invalid)
65+
"test\ud800text", # High surrogate in middle
66+
"สวัสดี\udc00", # Thai with low surrogate
67+
]
68+
69+
# Category: Emoji and Modern Unicode
70+
# Emoji, emoji sequences, and variations
71+
EMOJI_CASES = [
72+
"😀", # Basic emoji
73+
"👨‍👩‍👧‍👦", # Family emoji (ZWJ sequence)
74+
"👍🏻", # Emoji with skin tone modifier
75+
"🇹🇭", # Flag (regional indicator symbols)
76+
"😀😃😄", # Multiple emoji
77+
"สวัสดี😀ครับ", # Thai text with emoji
78+
"🏴󠁧󠁢󠁥󠁮󠁧󠁿", # Flag with tag sequences
79+
]
80+
81+
# Category: Control and Hidden Characters
82+
# Characters that don't display but affect processing
83+
CONTROL_AND_HIDDEN = [
84+
"\x01\x02\x03", # Control characters
85+
"\u0001\u0002\u0003", # Control chars unicode
86+
"\u200b", # Zero-width space
87+
"\u200c", # Zero-width non-joiner
88+
"\u200d", # Zero-width joiner
89+
"\ufeff", # Zero-width no-break space (BOM)
90+
"test\u200bword", # Text with zero-width space
91+
"ภาษา\u200cไทย", # Thai with ZWNJ
92+
"\u034f", # Combining grapheme joiner
93+
]
94+
95+
# Category: Thai-specific edge cases with combining characters
96+
THAI_EDGE_CASES = [
97+
"ก่ก้ก๊ก๋", # Multiple tone marks
98+
"ด้้้้้็็็็็", # Excessive combining marks
99+
"ไทย123English", # Mixed scripts
100+
"ๆๆๆ", # Repetition marks
101+
"\u200b\u200b\u200b", # Zero-width spaces
102+
]
103+
104+
# Category: Very Long Strings (issue #893)
105+
VERY_LONG_STRINGS = [
106+
"ชิ" * 50, # Repetitive single syllable
107+
"ด้านหน้า" * 20, # Repetitive compound word
108+
"ด้านหน้า" * 10 + "กกกกกก" * 10, # Mixed patterns
109+
]
110+
111+
def test_word_tokenize_empty_and_whitespace(self):
112+
"""
113+
Test word_tokenize with empty strings and various whitespace.
114+
115+
These cases are common in real-world usage from copy/paste,
116+
terminal input, and various text processing scenarios.
117+
"""
118+
for engine in self.TOKENIZE_ENGINES:
119+
for s in self.EMPTY_AND_WHITESPACE:
120+
with self.subTest(engine=engine, input_string=repr(s)):
121+
try:
122+
result = word_tokenize(s, engine=engine)
123+
self.assertIsInstance(result, list)
124+
except Exception as e:
125+
self.fail(
126+
f"word_tokenize (engine={engine}) failed with "
127+
f"whitespace case {repr(s)}: {e}"
128+
)
129+
130+
def test_word_tokenize_special_chars(self):
131+
"""
132+
Test word_tokenize with special characters.
133+
134+
Tests BOM, control characters, zero-width characters, and special
135+
punctuation that often appear from encoding issues or terminal copy/paste.
136+
"""
137+
for engine in self.TOKENIZE_ENGINES:
138+
for s in self.SPECIAL_CHARS:
139+
with self.subTest(engine=engine, input_string=repr(s)):
140+
try:
141+
result = word_tokenize(s, engine=engine)
142+
self.assertIsInstance(result, list)
143+
except Exception as e:
144+
self.fail(
145+
f"word_tokenize (engine={engine}) failed with "
146+
f"special char {repr(s)}: {e}"
147+
)
148+
149+
def test_word_tokenize_truncated_unicode(self):
150+
"""
151+
Test word_tokenize with truncated or malformed Unicode.
152+
153+
Tests handling of surrogate pairs and characters that might be
154+
cut in the middle of multi-byte encoding.
155+
"""
156+
for engine in self.TOKENIZE_ENGINES:
157+
for s in self.TRUNCATED_UNICODE:
158+
with self.subTest(engine=engine, input_string=repr(s)):
159+
try:
160+
result = word_tokenize(s, engine=engine)
161+
self.assertIsInstance(result, list)
162+
except Exception as e:
163+
# Truncated unicode might cause issues, but shouldn't crash
164+
self.fail(
165+
f"word_tokenize (engine={engine}) failed with "
166+
f"truncated unicode {repr(s)}: {e}"
167+
)
168+
169+
def test_word_tokenize_emoji(self):
170+
"""
171+
Test word_tokenize with emoji and modern Unicode sequences.
172+
173+
Tests emoji, emoji with modifiers, ZWJ sequences, and flags.
174+
Important for modern text processing.
175+
"""
176+
for engine in self.TOKENIZE_ENGINES:
177+
for s in self.EMOJI_CASES:
178+
with self.subTest(engine=engine, input_string=repr(s)):
179+
try:
180+
result = word_tokenize(s, engine=engine)
181+
self.assertIsInstance(result, list)
182+
if s.strip():
183+
self.assertGreater(len(result), 0)
184+
except Exception as e:
185+
self.fail(
186+
f"word_tokenize (engine={engine}) failed with "
187+
f"emoji {repr(s)}: {e}"
188+
)
189+
190+
def test_word_tokenize_control_and_hidden(self):
191+
"""
192+
Test word_tokenize with control and hidden characters.
193+
194+
Tests zero-width characters, control characters, and other
195+
invisible characters that affect text processing.
196+
"""
197+
for engine in self.TOKENIZE_ENGINES:
198+
for s in self.CONTROL_AND_HIDDEN:
199+
with self.subTest(engine=engine, input_string=repr(s)):
200+
try:
201+
result = word_tokenize(s, engine=engine)
202+
self.assertIsInstance(result, list)
203+
except Exception as e:
204+
self.fail(
205+
f"word_tokenize (engine={engine}) failed with "
206+
f"control/hidden char {repr(s)}: {e}"
207+
)
208+
209+
def test_word_tokenize_thai_edge_cases_multi_engine(self):
210+
"""
211+
Test word_tokenize with Thai edge cases across multiple engines.
212+
213+
Tests complex Thai character combinations including excessive combining
214+
marks, mixed scripts, and special characters across all core engines
215+
to ensure consistent handling.
216+
"""
217+
for engine in self.TOKENIZE_ENGINES:
218+
for s in self.THAI_EDGE_CASES:
219+
with self.subTest(engine=engine, input_string=repr(s)):
220+
try:
221+
result = word_tokenize(s, engine=engine)
222+
self.assertIsInstance(result, list)
223+
# Non-empty input should produce at least one token
224+
if s.strip():
225+
self.assertGreater(len(result), 0)
226+
except Exception as e:
227+
self.fail(
228+
f"word_tokenize (engine={engine}) failed with "
229+
f"Thai edge case {repr(s)}: {e}"
230+
)
231+
232+
def test_word_tokenize_with_very_long_strings(self):
233+
"""
234+
Test word_tokenize with very long strings.
235+
236+
This test addresses issue #893 - testing tokenization performance
237+
and robustness with very long strings, including repetitive patterns
238+
that can cause ambiguous breaking points and long processing times.
239+
240+
Uses newmm-safe engine which is designed to handle such cases.
241+
"""
242+
# Use newmm-safe which has safeguards against long processing times
243+
engine = "newmm-safe"
244+
for i, s in enumerate(self.VERY_LONG_STRINGS):
245+
with self.subTest(engine=engine, string_index=i):
246+
try:
247+
result = word_tokenize(s, engine=engine)
248+
self.assertIsInstance(result, list)
249+
# Very long strings should still produce tokens
250+
self.assertGreater(len(result), 0)
251+
except Exception as e:
252+
self.fail(
253+
f"word_tokenize (engine={engine}) failed with "
254+
f"very long string (index={i}): {e}"
255+
)
256+

0 commit comments

Comments
 (0)