Skip to content

Commit f46b0af

Browse files
committed
feat(plugin): implement width-safe Tiny Actor card renderer (#1269)
Add render_card() that produces compact 2-3 line cards using display-width-safe helpers from buddy_renderer. Handles CJK text, ANSI-colored faces, and ASCII mode without visual drift.
1 parent b9df988 commit f46b0af

2 files changed

Lines changed: 273 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Width-safe Tiny Actor card renderer (#1269).
2+
3+
Renders a compact 2-3 line card (face, label, optional quote) using
4+
display-width-safe helpers from ``buddy_renderer``. Every output line
5+
is guaranteed to have exactly ``card_width`` display columns.
6+
"""
7+
from __future__ import annotations
8+
9+
from typing import List
10+
11+
from tiny_actor_card import TinyActorCard
12+
from buddy_renderer import (
13+
display_width,
14+
pad_to_display_width,
15+
truncate_to_display_width,
16+
)
17+
18+
19+
def _center_to_width(text: str, width: int) -> str:
20+
"""Center *text* within *width* display columns.
21+
22+
Left-pads with spaces so the visible content sits roughly in the
23+
middle, then right-pads to exactly *width*.
24+
"""
25+
text_w = display_width(text)
26+
if text_w >= width:
27+
return pad_to_display_width(truncate_to_display_width(text, width), width)
28+
left_pad = (width - text_w) // 2
29+
return pad_to_display_width((" " * left_pad) + text, width)
30+
31+
32+
def render_card(
33+
card: TinyActorCard,
34+
*,
35+
card_width: int = 14,
36+
show_quote: bool = True,
37+
) -> List[str]:
38+
"""Render a :class:`TinyActorCard` as a list of display-width-safe lines.
39+
40+
Parameters
41+
----------
42+
card:
43+
The actor card to render.
44+
card_width:
45+
Target display width for every line (default 14).
46+
show_quote:
47+
When ``False``, the quote line is always omitted even if the
48+
card has a quote.
49+
50+
Returns
51+
-------
52+
list[str]
53+
2 or 3 lines, each exactly *card_width* display columns wide.
54+
"""
55+
lines: List[str] = []
56+
57+
# Line 1: face (centered)
58+
lines.append(_center_to_width(card.face, card_width))
59+
60+
# Line 2: label (centered, truncated if too wide)
61+
lines.append(_center_to_width(card.label, card_width))
62+
63+
# Line 3 (optional): quote
64+
if show_quote and card.quote is not None:
65+
truncated = truncate_to_display_width(card.quote, card_width)
66+
lines.append(pad_to_display_width(truncated, card_width))
67+
68+
return lines
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Tests for width-safe Tiny Actor card renderer (#1269)."""
2+
import os
3+
import sys
4+
5+
import pytest
6+
7+
# Ensure hooks/lib is on path
8+
_tests_dir = os.path.dirname(os.path.abspath(__file__))
9+
_lib_dir = os.path.join(os.path.dirname(_tests_dir), "hooks", "lib")
10+
if _lib_dir not in sys.path:
11+
sys.path.insert(0, _lib_dir)
12+
13+
from tiny_actor_card import TinyActorCard, create_actor_card
14+
from tiny_actor_renderer import render_card
15+
from buddy_renderer import display_width, strip_ansi
16+
17+
from typing import Optional
18+
19+
20+
# ---------------------------------------------------------------------------
21+
# Helpers
22+
# ---------------------------------------------------------------------------
23+
24+
def _make_card(
25+
label: str = "Security",
26+
mood: str = "speaking",
27+
quote: "Optional[str]" = "Checking…",
28+
color_ansi: "Optional[str]" = None,
29+
ascii_mode: bool = False,
30+
) -> TinyActorCard:
31+
return create_actor_card(
32+
agent_id="test-agent",
33+
label=label,
34+
mood=mood,
35+
quote=quote,
36+
color_ansi=color_ansi,
37+
ascii_mode=ascii_mode,
38+
)
39+
40+
41+
# ---------------------------------------------------------------------------
42+
# Basic rendering
43+
# ---------------------------------------------------------------------------
44+
45+
46+
class TestBasicRendering:
47+
"""render_card returns the correct number of lines."""
48+
49+
def test_card_with_quote_returns_three_lines(self):
50+
card = _make_card(quote="Hello")
51+
lines = render_card(card)
52+
assert len(lines) == 3
53+
54+
def test_card_without_quote_returns_two_lines(self):
55+
card = _make_card(quote=None)
56+
lines = render_card(card)
57+
assert len(lines) == 2
58+
59+
def test_show_quote_false_omits_quote_line(self):
60+
card = _make_card(quote="Hello")
61+
lines = render_card(card, show_quote=False)
62+
assert len(lines) == 2
63+
64+
65+
# ---------------------------------------------------------------------------
66+
# Display-width safety
67+
# ---------------------------------------------------------------------------
68+
69+
70+
class TestDisplayWidthSafety:
71+
"""All lines have equal display width matching card_width."""
72+
73+
def test_all_lines_equal_display_width(self):
74+
card = _make_card()
75+
lines = render_card(card, card_width=14)
76+
widths = [display_width(line) for line in lines]
77+
assert all(w == 14 for w in widths), f"widths: {widths}"
78+
79+
def test_custom_card_width(self):
80+
card = _make_card()
81+
lines = render_card(card, card_width=20)
82+
widths = [display_width(line) for line in lines]
83+
assert all(w == 20 for w in widths), f"widths: {widths}"
84+
85+
def test_narrow_card_width(self):
86+
card = _make_card(label="X", quote=None)
87+
lines = render_card(card, card_width=6)
88+
widths = [display_width(line) for line in lines]
89+
assert all(w == 6 for w in widths), f"widths: {widths}"
90+
91+
92+
# ---------------------------------------------------------------------------
93+
# CJK and multibyte text
94+
# ---------------------------------------------------------------------------
95+
96+
97+
class TestCJKTruncation:
98+
"""CJK labels and quotes are truncated to fit display width."""
99+
100+
def test_cjk_label_truncated_correctly(self):
101+
card = _make_card(label="보안전문가입니다", quote=None)
102+
lines = render_card(card, card_width=14)
103+
widths = [display_width(line) for line in lines]
104+
assert all(w == 14 for w in widths), f"widths: {widths}"
105+
106+
def test_japanese_label(self):
107+
card = _make_card(label="セキュリティ", quote=None)
108+
lines = render_card(card, card_width=14)
109+
widths = [display_width(line) for line in lines]
110+
assert all(w == 14 for w in widths), f"widths: {widths}"
111+
112+
def test_cjk_quote_truncated(self):
113+
card = _make_card(label="Sec", quote="한글 인용 테스트입니다")
114+
lines = render_card(card, card_width=14)
115+
widths = [display_width(line) for line in lines]
116+
assert all(w == 14 for w in widths), f"widths: {widths}"
117+
118+
119+
# ---------------------------------------------------------------------------
120+
# ANSI-colored faces
121+
# ---------------------------------------------------------------------------
122+
123+
124+
class TestANSIColoredFace:
125+
"""ANSI escape codes in face don't break alignment."""
126+
127+
def test_ansi_face_does_not_break_width(self):
128+
card = _make_card(color_ansi="red")
129+
# Wrap face in ANSI manually to simulate colored face
130+
colored_face = f"\033[31m{card.face}\033[0m"
131+
colored_card = TinyActorCard(
132+
agent_id=card.agent_id,
133+
label=card.label,
134+
face=colored_face,
135+
eye=card.eye,
136+
mood=card.mood,
137+
quote=card.quote,
138+
color_ansi=card.color_ansi,
139+
is_moderator=card.is_moderator,
140+
)
141+
lines = render_card(colored_card, card_width=14)
142+
widths = [display_width(line) for line in lines]
143+
assert all(w == 14 for w in widths), f"widths: {widths}"
144+
145+
def test_ansi_stripped_face_has_visible_content(self):
146+
card = _make_card()
147+
colored_face = f"\033[32m{card.face}\033[0m"
148+
colored_card = TinyActorCard(
149+
agent_id=card.agent_id,
150+
label=card.label,
151+
face=colored_face,
152+
eye=card.eye,
153+
mood=card.mood,
154+
quote=card.quote,
155+
color_ansi=card.color_ansi,
156+
is_moderator=card.is_moderator,
157+
)
158+
lines = render_card(colored_card, card_width=14)
159+
# Face line should contain visible characters
160+
assert len(strip_ansi(lines[0]).strip()) > 0
161+
162+
163+
# ---------------------------------------------------------------------------
164+
# ASCII mode
165+
# ---------------------------------------------------------------------------
166+
167+
168+
class TestASCIIMode:
169+
"""ASCII mode produces only ASCII characters in face."""
170+
171+
def test_ascii_mode_face_is_ascii_only(self):
172+
card = _make_card(ascii_mode=True)
173+
lines = render_card(card, card_width=14)
174+
face_line = strip_ansi(lines[0])
175+
assert face_line.isascii(), f"non-ASCII in face: {face_line!r}"
176+
177+
def test_ascii_mode_display_width_correct(self):
178+
card = _make_card(ascii_mode=True)
179+
lines = render_card(card, card_width=14)
180+
widths = [display_width(line) for line in lines]
181+
assert all(w == 14 for w in widths), f"widths: {widths}"
182+
183+
184+
# ---------------------------------------------------------------------------
185+
# Content correctness
186+
# ---------------------------------------------------------------------------
187+
188+
189+
class TestContentCorrectness:
190+
"""Rendered lines contain expected content."""
191+
192+
def test_face_appears_in_first_line(self):
193+
card = _make_card()
194+
lines = render_card(card, card_width=14)
195+
assert card.face in lines[0]
196+
197+
def test_label_appears_in_second_line(self):
198+
card = _make_card(label="Test")
199+
lines = render_card(card, card_width=14)
200+
assert "Test" in lines[1]
201+
202+
def test_quote_appears_in_third_line(self):
203+
card = _make_card(quote="Hi")
204+
lines = render_card(card, card_width=14)
205+
assert "Hi" in lines[2]

0 commit comments

Comments
 (0)