|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Alignment regression tests for Tiny Actor Grid (#1273). |
| 3 | +
|
| 4 | +Ensures display-width consistency across Unicode faces, ASCII fallback, |
| 5 | +CJK labels, emoji, ANSI color codes, and narrow terminal widths. |
| 6 | +
|
| 7 | +Run with: |
| 8 | + python3 -m pytest tests/test_tiny_actor_alignment.py -v |
| 9 | +""" |
| 10 | + |
| 11 | +import os |
| 12 | +import sys |
| 13 | +from pathlib import Path |
| 14 | + |
| 15 | +# Ensure hooks/lib is importable |
| 16 | +_hooks_lib = str(Path(__file__).resolve().parent.parent / "hooks" / "lib") |
| 17 | +if _hooks_lib not in sys.path: |
| 18 | + sys.path.insert(0, _hooks_lib) |
| 19 | + |
| 20 | +from buddy_renderer import ( # noqa: E402 |
| 21 | + display_width, |
| 22 | + pad_to_display_width, |
| 23 | + render_box_line, |
| 24 | + render_face_banner, |
| 25 | + strip_ansi, |
| 26 | + truncate_to_display_width, |
| 27 | +) |
| 28 | + |
| 29 | +# --------------------------------------------------------------------------- |
| 30 | +# Mock card data (inline — no external fixtures needed) |
| 31 | +# --------------------------------------------------------------------------- |
| 32 | + |
| 33 | +UNICODE_FACES = [ |
| 34 | + {"face": "\u25d5\u203f\u25d5", "label": "Security"}, |
| 35 | + {"face": "\u2b21\u203f\u2b21", "label": "Architecture"}, |
| 36 | + {"face": "\u2299\u203f\u2299", "label": "Performance"}, |
| 37 | + {"face": "\u25c9\u203f\u25c9", "label": "Testing"}, |
| 38 | + {"face": "\u00b0\u25c7\u00b0", "label": "Code Quality"}, |
| 39 | +] |
| 40 | + |
| 41 | +ASCII_FACES = [ |
| 42 | + {"face": "o_o", "label": "Security"}, |
| 43 | + {"face": "o.o", "label": "Architecture"}, |
| 44 | + {"face": "^_^", "label": "Performance"}, |
| 45 | + {"face": ">_<", "label": "Testing"}, |
| 46 | + {"face": "=_=", "label": "Code Quality"}, |
| 47 | +] |
| 48 | + |
| 49 | +CJK_LABELS = [ |
| 50 | + {"face": "\u25d5\u203f\u25d5", "label": "\ubcf4\uc548"}, # Korean: 보안 |
| 51 | + {"face": "\u2b21\u203f\u2b21", "label": "\uc131\ub2a5"}, # Korean: 성능 |
| 52 | + {"face": "\u2299\u203f\u2299", "label": "\uc811\uadfc\uc131"}, # Korean: 접근성 |
| 53 | + {"face": "\u25c9\u203f\u25c9", "label": "\u5b89\u5168"}, # Chinese: 安全 |
| 54 | + {"face": "\u00b0\u25c7\u00b0", "label": "\u6027\u80fd"}, # Chinese: 性能 |
| 55 | +] |
| 56 | + |
| 57 | +EMOJI_FACES = [ |
| 58 | + {"face": "\U0001f916", "label": "Bot"}, |
| 59 | + {"face": "\U0001f47b", "label": "Ghost"}, |
| 60 | + {"face": "\U0001f525\U0001f916", "label": "Fire Bot"}, |
| 61 | + {"face": "\u2728", "label": "Sparkle"}, |
| 62 | +] |
| 63 | + |
| 64 | +ANSI_COLORED_FACES = [ |
| 65 | + {"face": "\033[31m\u25d5\u203f\u25d5\033[0m", "label": "Red"}, |
| 66 | + {"face": "\033[32m\u2b21\u203f\u2b21\033[0m", "label": "Green"}, |
| 67 | + {"face": "\033[1;34m\u2299\u203f\u2299\033[0m", "label": "Bold Blue"}, |
| 68 | + {"face": "\033[38;5;208m^_^\033[0m", "label": "Orange"}, |
| 69 | +] |
| 70 | + |
| 71 | + |
| 72 | +def _make_card_line(face: str, label: str, target_width: int) -> str: |
| 73 | + """Simulate a grid cell: '| <face> <label> |' padded to target_width.""" |
| 74 | + inner = f" {face} {label} " |
| 75 | + return pad_to_display_width(inner, target_width) |
| 76 | + |
| 77 | + |
| 78 | +def _make_box_rows(face: str, label: str, inner_width: int) -> list: |
| 79 | + """Build a simple boxed card: top border, face row, label row, bottom.""" |
| 80 | + top = "\u2500" * (inner_width + 2) |
| 81 | + face_cell = pad_to_display_width(f" {face} ", inner_width + 2) |
| 82 | + label_cell = pad_to_display_width(f" {label} ", inner_width + 2) |
| 83 | + return [ |
| 84 | + f"\u250c{top}\u2510", |
| 85 | + f"\u2502{face_cell}\u2502", |
| 86 | + f"\u2502{label_cell}\u2502", |
| 87 | + f"\u2514{top}\u2518", |
| 88 | + ] |
| 89 | + |
| 90 | + |
| 91 | +# =================================================================== |
| 92 | +# 1. Unicode mode — standard agent faces |
| 93 | +# =================================================================== |
| 94 | + |
| 95 | +class TestUnicodeFaceAlignment: |
| 96 | + """Cards with Unicode faces (◕‿◕ etc.) maintain consistent width.""" |
| 97 | + |
| 98 | + def test_all_face_banners_have_matching_border_widths(self): |
| 99 | + for card in UNICODE_FACES: |
| 100 | + lines = render_face_banner(card["face"]) |
| 101 | + top, middle, bottom = lines[0], lines[1], lines[2] |
| 102 | + assert display_width(top) == display_width(bottom), ( |
| 103 | + f"Top/bottom mismatch for {card['label']}: " |
| 104 | + f"top={display_width(top)}, bottom={display_width(bottom)}" |
| 105 | + ) |
| 106 | + |
| 107 | + def test_padded_cells_have_equal_display_width(self): |
| 108 | + target = 20 |
| 109 | + widths = [] |
| 110 | + for card in UNICODE_FACES: |
| 111 | + cell = _make_card_line(card["face"], card["label"], target) |
| 112 | + w = display_width(cell) |
| 113 | + widths.append((card["label"], w)) |
| 114 | + first_width = widths[0][1] |
| 115 | + for label, w in widths: |
| 116 | + assert w == first_width, ( |
| 117 | + f"Width mismatch: {label}={w}, expected={first_width}" |
| 118 | + ) |
| 119 | + |
| 120 | + def test_render_box_line_uniform_width(self): |
| 121 | + width = 24 |
| 122 | + for card in UNICODE_FACES: |
| 123 | + line = render_box_line(f" {card['face']} {card['label']} ", width) |
| 124 | + assert display_width(line) == width + 2, ( |
| 125 | + f"box_line width for {card['label']}: " |
| 126 | + f"got {display_width(line)}, expected {width + 2}" |
| 127 | + ) |
| 128 | + |
| 129 | + |
| 130 | +# =================================================================== |
| 131 | +# 2. ASCII fallback mode |
| 132 | +# =================================================================== |
| 133 | + |
| 134 | +class TestAsciiFallbackAlignment: |
| 135 | + """ASCII-only faces (o_o, ^_^) keep consistent width with no Unicode.""" |
| 136 | + |
| 137 | + def test_no_unicode_in_ascii_faces(self): |
| 138 | + for card in ASCII_FACES: |
| 139 | + for ch in card["face"]: |
| 140 | + assert ord(ch) < 128, ( |
| 141 | + f"Non-ASCII char U+{ord(ch):04X} in face '{card['face']}'" |
| 142 | + ) |
| 143 | + |
| 144 | + def test_face_banner_border_match(self): |
| 145 | + for card in ASCII_FACES: |
| 146 | + lines = render_face_banner(card["face"]) |
| 147 | + assert display_width(lines[0]) == display_width(lines[2]) |
| 148 | + |
| 149 | + def test_padded_cells_equal_width(self): |
| 150 | + target = 18 |
| 151 | + widths = set() |
| 152 | + for card in ASCII_FACES: |
| 153 | + cell = _make_card_line(card["face"], card["label"], target) |
| 154 | + widths.add(display_width(cell)) |
| 155 | + assert len(widths) == 1, f"Non-uniform widths: {widths}" |
| 156 | + |
| 157 | + |
| 158 | +# =================================================================== |
| 159 | +# 3. CJK text in labels |
| 160 | +# =================================================================== |
| 161 | + |
| 162 | +class TestCjkLabelAlignment: |
| 163 | + """Labels with CJK double-width characters stay aligned.""" |
| 164 | + |
| 165 | + def test_cjk_chars_are_double_width(self): |
| 166 | + assert display_width("\ubcf4\uc548") == 4 # 보안: 2 chars x 2 width |
| 167 | + assert display_width("\u5b89\u5168") == 4 # 安全: 2 chars x 2 width |
| 168 | + assert display_width("\uc811\uadfc\uc131") == 6 # 접근성: 3 chars x 2 width |
| 169 | + |
| 170 | + def test_padded_cells_uniform_despite_cjk(self): |
| 171 | + target = 22 |
| 172 | + widths = set() |
| 173 | + for card in CJK_LABELS: |
| 174 | + cell = _make_card_line(card["face"], card["label"], target) |
| 175 | + widths.add(display_width(cell)) |
| 176 | + assert len(widths) == 1, f"CJK width drift: {widths}" |
| 177 | + |
| 178 | + def test_box_rows_consistent(self): |
| 179 | + inner = 18 |
| 180 | + for card in CJK_LABELS: |
| 181 | + rows = _make_box_rows(card["face"], card["label"], inner) |
| 182 | + expected = display_width(rows[0]) |
| 183 | + for i, row in enumerate(rows): |
| 184 | + assert display_width(row) == expected, ( |
| 185 | + f"Row {i} width mismatch for '{card['label']}': " |
| 186 | + f"got {display_width(row)}, expected {expected}" |
| 187 | + ) |
| 188 | + |
| 189 | + def test_mixed_cjk_ascii_labels(self): |
| 190 | + """CJK and ASCII labels padded to same width produce equal display width.""" |
| 191 | + target = 24 |
| 192 | + mixed = [ |
| 193 | + {"face": "\u25d5\u203f\u25d5", "label": "\ubcf4\uc548 Check"}, |
| 194 | + {"face": "\u2b21\u203f\u2b21", "label": "Security"}, |
| 195 | + {"face": "\u2299\u203f\u2299", "label": "\u6027\u80fd Test"}, |
| 196 | + ] |
| 197 | + widths = set() |
| 198 | + for card in mixed: |
| 199 | + cell = _make_card_line(card["face"], card["label"], target) |
| 200 | + widths.add(display_width(cell)) |
| 201 | + assert len(widths) == 1, f"Mixed CJK/ASCII width drift: {widths}" |
| 202 | + |
| 203 | + |
| 204 | +# =================================================================== |
| 205 | +# 4. Emoji in face or status |
| 206 | +# =================================================================== |
| 207 | + |
| 208 | +class TestEmojiAlignment: |
| 209 | + """Emoji characters should not break alignment.""" |
| 210 | + |
| 211 | + def test_emoji_face_display_width(self): |
| 212 | + # Common emoji should be treated as double-width |
| 213 | + assert display_width("\U0001f916") == 2 # robot |
| 214 | + assert display_width("\U0001f47b") == 2 # ghost |
| 215 | + assert display_width("\U0001f525") == 2 # fire |
| 216 | + |
| 217 | + def test_emoji_face_banners_aligned(self): |
| 218 | + for card in EMOJI_FACES: |
| 219 | + lines = render_face_banner(card["face"]) |
| 220 | + assert display_width(lines[0]) == display_width(lines[2]), ( |
| 221 | + f"Emoji banner mismatch for {card['label']}" |
| 222 | + ) |
| 223 | + |
| 224 | + def test_emoji_padded_cells_uniform(self): |
| 225 | + target = 20 |
| 226 | + widths = set() |
| 227 | + for card in EMOJI_FACES: |
| 228 | + cell = _make_card_line(card["face"], card["label"], target) |
| 229 | + widths.add(display_width(cell)) |
| 230 | + assert len(widths) == 1, f"Emoji cell width drift: {widths}" |
| 231 | + |
| 232 | + |
| 233 | +# =================================================================== |
| 234 | +# 5. ANSI color codes |
| 235 | +# =================================================================== |
| 236 | + |
| 237 | +class TestAnsiColorAlignment: |
| 238 | + """ANSI escape codes should have zero display width.""" |
| 239 | + |
| 240 | + def test_ansi_codes_zero_width(self): |
| 241 | + plain = "\u25d5\u203f\u25d5" |
| 242 | + colored = "\033[31m\u25d5\u203f\u25d5\033[0m" |
| 243 | + assert display_width(plain) == display_width(colored) |
| 244 | + |
| 245 | + def test_strip_ansi_removes_all_codes(self): |
| 246 | + colored = "\033[1;38;5;208mhello\033[0m" |
| 247 | + assert strip_ansi(colored) == "hello" |
| 248 | + |
| 249 | + def test_ansi_face_banners_match_plain(self): |
| 250 | + plain_face = "\u25d5\u203f\u25d5" |
| 251 | + ansi_face = "\033[31m\u25d5\u203f\u25d5\033[0m" |
| 252 | + plain_lines = render_face_banner(plain_face) |
| 253 | + ansi_lines = render_face_banner(ansi_face) |
| 254 | + for i in range(len(plain_lines)): |
| 255 | + assert display_width(plain_lines[i]) == display_width(ansi_lines[i]), ( |
| 256 | + f"ANSI banner line {i} width differs from plain" |
| 257 | + ) |
| 258 | + |
| 259 | + def test_ansi_colored_cells_uniform(self): |
| 260 | + target = 22 |
| 261 | + widths = set() |
| 262 | + for card in ANSI_COLORED_FACES: |
| 263 | + cell = _make_card_line(card["face"], card["label"], target) |
| 264 | + widths.add(display_width(cell)) |
| 265 | + assert len(widths) == 1, f"ANSI cell width drift: {widths}" |
| 266 | + |
| 267 | + |
| 268 | +# =================================================================== |
| 269 | +# 6. Narrow terminal widths |
| 270 | +# =================================================================== |
| 271 | + |
| 272 | +class TestNarrowTerminalAlignment: |
| 273 | + """Grid should not break at narrow terminal widths.""" |
| 274 | + |
| 275 | + def test_box_line_at_40_columns(self): |
| 276 | + width = 38 # inner width for 40-col terminal (minus box borders) |
| 277 | + for card in UNICODE_FACES: |
| 278 | + line = render_box_line( |
| 279 | + f" {card['face']} {card['label']} ", width |
| 280 | + ) |
| 281 | + assert display_width(line) == width + 2 |
| 282 | + |
| 283 | + def test_box_line_at_80_columns(self): |
| 284 | + width = 78 # inner width for 80-col terminal |
| 285 | + for card in UNICODE_FACES: |
| 286 | + line = render_box_line( |
| 287 | + f" {card['face']} {card['label']} ", width |
| 288 | + ) |
| 289 | + assert display_width(line) == width + 2 |
| 290 | + |
| 291 | + def test_truncation_respects_narrow_width(self): |
| 292 | + long_text = "\u25d5\u203f\u25d5 Security Specialist \ubcf4\uc548" |
| 293 | + for w in (10, 15, 20, 30): |
| 294 | + result = truncate_to_display_width(long_text, w) |
| 295 | + assert display_width(result) <= w, ( |
| 296 | + f"Truncated to {w} but got width {display_width(result)}" |
| 297 | + ) |
| 298 | + |
| 299 | + def test_all_card_types_at_narrow_width(self): |
| 300 | + """All card types stay uniform at 40 columns.""" |
| 301 | + all_cards = UNICODE_FACES + ASCII_FACES + CJK_LABELS |
| 302 | + target = 36 |
| 303 | + widths = set() |
| 304 | + for card in all_cards: |
| 305 | + cell = _make_card_line(card["face"], card["label"], target) |
| 306 | + widths.add(display_width(cell)) |
| 307 | + assert len(widths) == 1, f"Narrow-width drift across card types: {widths}" |
| 308 | + |
| 309 | + |
| 310 | +# =================================================================== |
| 311 | +# 7. Integration: mixed content in a single grid row |
| 312 | +# =================================================================== |
| 313 | + |
| 314 | +class TestMixedGridRow: |
| 315 | + """Simulate a row with different content types — all cells same width.""" |
| 316 | + |
| 317 | + def test_mixed_row_uniform(self): |
| 318 | + """Unicode, CJK, emoji, ANSI cards in one row have equal width.""" |
| 319 | + target = 26 |
| 320 | + cards = [ |
| 321 | + {"face": "\u25d5\u203f\u25d5", "label": "Plain"}, |
| 322 | + {"face": "\u25d5\u203f\u25d5", "label": "\ubcf4\uc548"}, |
| 323 | + {"face": "\U0001f916", "label": "Bot"}, |
| 324 | + {"face": "\033[31m\u25d5\u203f\u25d5\033[0m", "label": "Red"}, |
| 325 | + ] |
| 326 | + widths = set() |
| 327 | + for card in cards: |
| 328 | + cell = _make_card_line(card["face"], card["label"], target) |
| 329 | + widths.add(display_width(cell)) |
| 330 | + assert len(widths) == 1, f"Mixed row width drift: {widths}" |
| 331 | + |
| 332 | + def test_face_banner_widths_stable_across_types(self): |
| 333 | + """render_face_banner top/bottom always match for any face type.""" |
| 334 | + faces = [ |
| 335 | + "\u25d5\u203f\u25d5", # Unicode |
| 336 | + "o_o", # ASCII |
| 337 | + "\U0001f916", # Emoji |
| 338 | + "\033[32m\u25d5\u203f\u25d5\033[0m", # ANSI |
| 339 | + "\u2b21\u203f\u2b21", # Unicode alt |
| 340 | + ] |
| 341 | + for face in faces: |
| 342 | + lines = render_face_banner(face) |
| 343 | + top_w = display_width(lines[0]) |
| 344 | + bottom_w = display_width(lines[2]) |
| 345 | + assert top_w == bottom_w, ( |
| 346 | + f"Face banner mismatch for '{strip_ansi(face)}': " |
| 347 | + f"top={top_w}, bottom={bottom_w}" |
| 348 | + ) |
0 commit comments