Skip to content

Commit 8de2501

Browse files
committed
feat(plugin): add TinyActorCard contract and mood-to-face mapping (#1270)
Introduce a stable, reusable data contract for the Tiny Actor Grid: - TinyActorCard frozen dataclass with agent_id, label, face, eye, mood, quote, color_ansi, and is_moderator fields - build_face() deterministic face builder with 5 mood mappings (speaking, reviewing, proposing, unsure, blocked) - ASCII fallback mode for terminals without Unicode support - create_actor_card() factory with eye_glyph and moderator support - 25 unit tests covering all moods, custom eyes, ASCII fallback, dataclass contract, and Buddy moderator separation Closes #1270
1 parent 573c04c commit 8de2501

2 files changed

Lines changed: 350 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""TinyActorCard contract and mood-to-face mapping for ASCII actor scenes (#1270).
2+
3+
Provides a stable, reusable data contract for the Tiny Actor Grid.
4+
Face generation is deterministic from ``eye + mood``.
5+
"""
6+
from __future__ import annotations
7+
8+
from dataclasses import dataclass, field
9+
from typing import Optional
10+
11+
# ---------------------------------------------------------------------------
12+
# Constants
13+
# ---------------------------------------------------------------------------
14+
15+
DEFAULT_EYE: str = "\u25cf" # ●
16+
DEFAULT_EYE_ASCII: str = "o"
17+
18+
# Unicode mouth glyphs keyed by mood
19+
_MOOD_MOUTHS: dict[str, str] = {
20+
"speaking": "\u203f", # ‿
21+
"reviewing": "\u2304", # ⌄
22+
"proposing": "\u2040", # ⁀
23+
"unsure": "_",
24+
"blocked": "x",
25+
}
26+
27+
# ASCII mouth glyphs keyed by mood
28+
_MOOD_MOUTHS_ASCII: dict[str, str] = {
29+
"speaking": "_",
30+
"reviewing": ".",
31+
"proposing": "~",
32+
"unsure": "_",
33+
"blocked": "x",
34+
}
35+
36+
_DEFAULT_MOOD = "unsure"
37+
38+
39+
# ---------------------------------------------------------------------------
40+
# Dataclass
41+
# ---------------------------------------------------------------------------
42+
43+
44+
@dataclass(frozen=True)
45+
class TinyActorCard:
46+
"""Immutable card representing a single actor in the Tiny Actor Grid."""
47+
48+
agent_id: str
49+
label: str
50+
face: str
51+
eye: str
52+
mood: str
53+
quote: Optional[str] = None
54+
color_ansi: Optional[str] = None
55+
is_moderator: bool = False
56+
57+
58+
# ---------------------------------------------------------------------------
59+
# Face builder
60+
# ---------------------------------------------------------------------------
61+
62+
63+
def build_face(
64+
mood: str,
65+
eye_glyph: Optional[str] = None,
66+
*,
67+
eye_fallback: Optional[str] = None,
68+
ascii_mode: bool = False,
69+
) -> str:
70+
"""Build a deterministic face string from *mood* and optional eye glyph.
71+
72+
Parameters
73+
----------
74+
mood:
75+
Mood key (``speaking``, ``reviewing``, ``proposing``, ``unsure``,
76+
``blocked``). Unknown moods fall back to ``unsure``.
77+
eye_glyph:
78+
Unicode eye character (e.g. ``★``). Ignored when *ascii_mode* is
79+
``True``.
80+
eye_fallback:
81+
ASCII single-char eye used when *ascii_mode* is ``True``.
82+
Defaults to ``o``.
83+
ascii_mode:
84+
When ``True``, produce a pure-ASCII face.
85+
"""
86+
if ascii_mode:
87+
eye = eye_fallback or DEFAULT_EYE_ASCII
88+
mouth = _MOOD_MOUTHS_ASCII.get(mood, _MOOD_MOUTHS_ASCII[_DEFAULT_MOOD])
89+
return f"{eye}{mouth}{eye}"
90+
91+
eye = eye_glyph or DEFAULT_EYE
92+
mouth = _MOOD_MOUTHS.get(mood, _MOOD_MOUTHS[_DEFAULT_MOOD])
93+
return f"{eye}{mouth}{eye}"
94+
95+
96+
# ---------------------------------------------------------------------------
97+
# Factory
98+
# ---------------------------------------------------------------------------
99+
100+
101+
def create_actor_card(
102+
agent_id: str,
103+
label: str,
104+
*,
105+
mood: str = _DEFAULT_MOOD,
106+
eye_glyph: Optional[str] = None,
107+
eye_fallback: Optional[str] = None,
108+
quote: Optional[str] = None,
109+
color_ansi: Optional[str] = None,
110+
is_moderator: bool = False,
111+
ascii_mode: bool = False,
112+
) -> TinyActorCard:
113+
"""Create a :class:`TinyActorCard` with a deterministic face.
114+
115+
Parameters
116+
----------
117+
agent_id:
118+
Agent identifier (e.g. ``"security-specialist"``).
119+
label:
120+
Short display name (e.g. ``"Security"``).
121+
mood:
122+
Mood key — defaults to ``"unsure"``.
123+
eye_glyph:
124+
Unicode eye glyph from agent JSON ``visual.eye``.
125+
eye_fallback:
126+
ASCII eye character for fallback rendering.
127+
quote:
128+
Optional status text or quote.
129+
color_ansi:
130+
ANSI colour name (``red``, ``green``, …).
131+
is_moderator:
132+
``True`` for Buddy / moderator cards.
133+
ascii_mode:
134+
Produce a pure-ASCII face when ``True``.
135+
"""
136+
face = build_face(
137+
mood,
138+
eye_glyph=eye_glyph,
139+
eye_fallback=eye_fallback,
140+
ascii_mode=ascii_mode,
141+
)
142+
eye = (eye_fallback or DEFAULT_EYE_ASCII) if ascii_mode else (eye_glyph or DEFAULT_EYE)
143+
144+
return TinyActorCard(
145+
agent_id=agent_id,
146+
label=label,
147+
face=face,
148+
eye=eye,
149+
mood=mood,
150+
quote=quote,
151+
color_ansi=color_ansi,
152+
is_moderator=is_moderator,
153+
)
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""Tests for TinyActorCard contract and mood-to-face mapping (#1270)."""
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, build_face, create_actor_card
14+
15+
# ---------------------------------------------------------------------------
16+
# build_face — mood-to-face mapping
17+
# ---------------------------------------------------------------------------
18+
19+
DEFAULT_EYE = "\u25cf" # ●
20+
21+
22+
class TestBuildFaceMoods:
23+
"""build_face returns the correct face for each mood."""
24+
25+
def test_speaking_mood(self):
26+
assert build_face("speaking") == f"{DEFAULT_EYE}\u203f{DEFAULT_EYE}"
27+
28+
def test_reviewing_mood(self):
29+
assert build_face("reviewing") == f"{DEFAULT_EYE}\u2304{DEFAULT_EYE}"
30+
31+
def test_proposing_mood(self):
32+
assert build_face("proposing") == f"{DEFAULT_EYE}\u2040{DEFAULT_EYE}"
33+
34+
def test_unsure_mood(self):
35+
assert build_face("unsure") == f"{DEFAULT_EYE}_{DEFAULT_EYE}"
36+
37+
def test_blocked_mood(self):
38+
assert build_face("blocked") == f"{DEFAULT_EYE}x{DEFAULT_EYE}"
39+
40+
def test_unknown_mood_falls_back_to_unsure(self):
41+
assert build_face("nonexistent") == f"{DEFAULT_EYE}_{DEFAULT_EYE}"
42+
43+
44+
class TestBuildFaceCustomEye:
45+
"""build_face with custom eye_glyph replaces the default eye."""
46+
47+
def test_star_eye(self):
48+
assert build_face("speaking", eye_glyph="\u2605") == "\u2605\u203f\u2605"
49+
50+
def test_diamond_eye(self):
51+
assert build_face("reviewing", eye_glyph="\u25c6") == "\u25c6\u2304\u25c6"
52+
53+
def test_buddy_eye(self):
54+
# Buddy uses ◕ (U+25D5)
55+
assert build_face("proposing", eye_glyph="\u25d5") == "\u25d5\u2040\u25d5"
56+
57+
58+
class TestBuildFaceAsciiFallback:
59+
"""build_face ASCII fallback mode produces safe ASCII faces."""
60+
61+
def test_speaking_ascii(self):
62+
assert build_face("speaking", ascii_mode=True) == "o_o"
63+
64+
def test_reviewing_ascii(self):
65+
assert build_face("reviewing", ascii_mode=True) == "o.o"
66+
67+
def test_proposing_ascii(self):
68+
assert build_face("proposing", ascii_mode=True) == "o~o"
69+
70+
def test_unsure_ascii(self):
71+
assert build_face("unsure", ascii_mode=True) == "o_o"
72+
73+
def test_blocked_ascii(self):
74+
assert build_face("blocked", ascii_mode=True) == "oxo"
75+
76+
def test_ascii_with_eye_fallback(self):
77+
assert build_face("speaking", eye_fallback="O", ascii_mode=True) == "O_O"
78+
79+
def test_ascii_unknown_mood(self):
80+
assert build_face("whatever", ascii_mode=True) == "o_o"
81+
82+
83+
# ---------------------------------------------------------------------------
84+
# TinyActorCard — dataclass contract
85+
# ---------------------------------------------------------------------------
86+
87+
88+
class TestTinyActorCard:
89+
"""TinyActorCard dataclass holds the expected fields."""
90+
91+
def test_all_fields_present(self):
92+
card = TinyActorCard(
93+
agent_id="security-specialist",
94+
label="Security",
95+
face="\u2605\u203f\u2605",
96+
eye="\u2605",
97+
mood="speaking",
98+
quote="analyzing",
99+
color_ansi="yellow",
100+
is_moderator=False,
101+
)
102+
assert card.agent_id == "security-specialist"
103+
assert card.label == "Security"
104+
assert card.face == "\u2605\u203f\u2605"
105+
assert card.eye == "\u2605"
106+
assert card.mood == "speaking"
107+
assert card.quote == "analyzing"
108+
assert card.color_ansi == "yellow"
109+
assert card.is_moderator is False
110+
111+
def test_optional_fields_default(self):
112+
card = TinyActorCard(
113+
agent_id="test",
114+
label="Test",
115+
face="o_o",
116+
eye=DEFAULT_EYE,
117+
mood="unsure",
118+
)
119+
assert card.quote is None
120+
assert card.color_ansi is None
121+
assert card.is_moderator is False
122+
123+
124+
# ---------------------------------------------------------------------------
125+
# create_actor_card — factory function
126+
# ---------------------------------------------------------------------------
127+
128+
129+
class TestCreateActorCard:
130+
"""create_actor_card factory builds valid cards."""
131+
132+
def test_creates_card_with_defaults(self):
133+
card = create_actor_card("frontend-developer", "Frontend")
134+
assert card.agent_id == "frontend-developer"
135+
assert card.label == "Frontend"
136+
assert card.mood == "unsure" # default mood
137+
assert card.eye == DEFAULT_EYE
138+
assert card.face == f"{DEFAULT_EYE}_{DEFAULT_EYE}"
139+
assert card.quote is None
140+
assert card.color_ansi is None
141+
assert card.is_moderator is False
142+
143+
def test_creates_card_with_custom_mood(self):
144+
card = create_actor_card("backend-developer", "Backend", mood="speaking")
145+
assert card.mood == "speaking"
146+
assert card.face == f"{DEFAULT_EYE}\u203f{DEFAULT_EYE}"
147+
148+
def test_creates_card_with_eye_glyph(self):
149+
card = create_actor_card(
150+
"security-specialist", "Security", eye_glyph="\u2605"
151+
)
152+
assert card.eye == "\u2605"
153+
assert card.face == "\u2605_\u2605"
154+
155+
def test_creates_card_with_all_options(self):
156+
card = create_actor_card(
157+
"test-engineer",
158+
"Test",
159+
mood="proposing",
160+
eye_glyph="\u25c6",
161+
quote="running tests",
162+
color_ansi="green",
163+
is_moderator=False,
164+
)
165+
assert card.agent_id == "test-engineer"
166+
assert card.face == "\u25c6\u2040\u25c6"
167+
assert card.quote == "running tests"
168+
assert card.color_ansi == "green"
169+
170+
def test_buddy_moderator_card(self):
171+
card = create_actor_card(
172+
"buddy",
173+
"Buddy",
174+
mood="speaking",
175+
eye_glyph="\u25d5",
176+
is_moderator=True,
177+
)
178+
assert card.is_moderator is True
179+
assert card.eye == "\u25d5"
180+
assert card.face == "\u25d5\u203f\u25d5"
181+
182+
def test_ascii_mode_card(self):
183+
card = create_actor_card(
184+
"test", "Test", mood="speaking", ascii_mode=True
185+
)
186+
assert card.face == "o_o"
187+
188+
def test_ascii_mode_with_eye_fallback(self):
189+
card = create_actor_card(
190+
"test",
191+
"Test",
192+
mood="reviewing",
193+
eye_fallback="O",
194+
ascii_mode=True,
195+
)
196+
assert card.face == "O.O"
197+
assert card.eye == "O"

0 commit comments

Comments
 (0)