diff --git a/packages/claude-code-plugin/hooks/lib/achievement_tracker.py b/packages/claude-code-plugin/hooks/lib/achievement_tracker.py new file mode 100644 index 00000000..29c17002 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/achievement_tracker.py @@ -0,0 +1,415 @@ +"""Achievement and badge system for coding habits (#1008). + +Tracks coding habit milestones (TDD cycles, agent usage, quality scores, +commit velocity, streak days) and awards achievements when thresholds are met. +Uses fcntl.flock() for file-level locking on every IO operation. +""" +import json +import os +import time +from typing import Any, Dict, List, Optional + +try: + import fcntl + + HAS_FCNTL = True +except ImportError: + HAS_FCNTL = False + +DEFAULT_DATA_DIR = os.path.join(os.path.expanduser("~"), ".codingbuddy") +ACHIEVEMENTS_SUBDIR = "achievements" + +# Achievement definitions +ACHIEVEMENT_DEFINITIONS: List[Dict[str, Any]] = [ + { + "id": "tdd_champion", + "name": "TDD Champion", + "description": "Complete 100 TDD cycles", + "metric": "tdd_cycles", + "threshold": 100, + "icon": "\U0001f3c6", # trophy + "face": "\u2605\u203f\u2605", # star eyes + }, + { + "id": "agent_master", + "name": "Agent Master", + "description": "Use 10 or more different agents", + "metric": "unique_agents", + "threshold": 10, + "icon": "\U0001f916", # robot + "face": "\u25c8\u203f\u25c8", # diamond eyes + }, + { + "id": "quality_guard", + "name": "Quality Guard", + "description": "Achieve EVAL score 90+", + "metric": "max_eval_score", + "threshold": 90, + "icon": "\U0001f6e1\ufe0f", # shield + "face": "\u25b2\u203f\u25b2", # triangle eyes + }, + { + "id": "speed_coder", + "name": "Speed Coder", + "description": "Make 5 commits within 1 hour", + "metric": "max_commits_per_hour", + "threshold": 5, + "icon": "\u26a1", # lightning + "face": "\u25ba\u203f\u25ba", # arrow eyes + }, + { + "id": "streak", + "name": "Streak Master", + "description": "Code for 5 consecutive days", + "metric": "max_streak_days", + "threshold": 5, + "icon": "\U0001f525", # fire + "face": "\u2666\u203f\u2666", # diamond eyes + }, +] + +# Celebration messages by language +CELEBRATION_MESSAGES: Dict[str, Dict[str, str]] = { + "en": { + "unlocked": "Achievement Unlocked!", + "congrats": "Congratulations!", + }, + "ko": { + "unlocked": "업적 달성!", + "congrats": "축하합니다!", + }, + "ja": { + "unlocked": "実績解除!", + "congrats": "おめでとうございます!", + }, + "zh": { + "unlocked": "成就解锁!", + "congrats": "恭喜!", + }, + "es": { + "unlocked": "Logro desbloqueado!", + "congrats": "Felicitaciones!", + }, +} + +# Badge display headers by language +BADGE_HEADERS: Dict[str, str] = { + "en": "Achievements", + "ko": "업적", + "ja": "実績", + "zh": "成就", + "es": "Logros", +} + + +def get_achievement_definitions() -> List[Dict[str, Any]]: + """Return the list of all achievement definitions.""" + return list(ACHIEVEMENT_DEFINITIONS) + + +def get_achievement_by_id(achievement_id: str) -> Optional[Dict[str, Any]]: + """Look up an achievement definition by its ID. + + Args: + achievement_id: The achievement identifier string. + + Returns: + Achievement definition dict, or None if not found. + """ + for defn in ACHIEVEMENT_DEFINITIONS: + if defn["id"] == achievement_id: + return dict(defn) + return None + + +class AchievementTracker: + """Track and award coding habit achievements.""" + + def __init__(self, data_dir: Optional[str] = None): + """Initialize the achievement tracker. + + Args: + data_dir: Base directory for data storage. + Uses CLAUDE_PLUGIN_DATA env or ~/.codingbuddy. + """ + if data_dir is None: + data_dir = os.environ.get("CLAUDE_PLUGIN_DATA", DEFAULT_DATA_DIR) + + self.achievements_dir = os.path.join(data_dir, ACHIEVEMENTS_SUBDIR) + os.makedirs(self.achievements_dir, mode=0o700, exist_ok=True) + + self.progress_file = os.path.join(self.achievements_dir, "progress.json") + self.unlocked_file = os.path.join(self.achievements_dir, "unlocked.json") + + # Initialize files if they don't exist + if not os.path.exists(self.progress_file): + self._locked_write(self.progress_file, self._default_progress()) + if not os.path.exists(self.unlocked_file): + self._locked_write(self.unlocked_file, []) + + def _default_progress(self) -> Dict[str, Any]: + """Return default progress structure.""" + return { + "tdd_cycles": 0, + "unique_agents": [], + "max_eval_score": 0, + "max_commits_per_hour": 0, + "commit_timestamps": [], + "session_dates": [], + "max_streak_days": 0, + } + + def get_progress(self) -> Dict[str, Any]: + """Read current progress from disk. + + Returns: + Progress dict with metric values. + """ + return self._locked_read(self.progress_file, self._default_progress()) + + def get_unlocked(self) -> List[Dict[str, Any]]: + """Read list of unlocked achievements. + + Returns: + List of unlocked achievement records. + """ + return self._locked_read(self.unlocked_file, []) + + def record_tdd_cycle(self) -> None: + """Record a completed TDD cycle.""" + progress = self.get_progress() + progress["tdd_cycles"] = progress.get("tdd_cycles", 0) + 1 + self._locked_write(self.progress_file, progress) + + def record_agent_usage(self, agent_name: str) -> None: + """Record usage of a specific agent. + + Args: + agent_name: Name of the agent used. + """ + progress = self.get_progress() + agents = progress.get("unique_agents", []) + if agent_name and agent_name not in agents: + agents.append(agent_name) + progress["unique_agents"] = agents + self._locked_write(self.progress_file, progress) + + def record_eval_score(self, score: int) -> None: + """Record an EVAL mode score. + + Args: + score: The evaluation score (0-100). + """ + progress = self.get_progress() + if score > progress.get("max_eval_score", 0): + progress["max_eval_score"] = score + self._locked_write(self.progress_file, progress) + + def record_commit(self) -> None: + """Record a commit timestamp for speed tracking.""" + progress = self.get_progress() + now = time.time() + timestamps = progress.get("commit_timestamps", []) + timestamps.append(now) + # Keep only last 24h of timestamps + cutoff = now - 86400 + timestamps = [t for t in timestamps if t > cutoff] + progress["commit_timestamps"] = timestamps + + # Calculate max commits in any 1-hour window + max_in_hour = self._max_commits_in_window(timestamps, 3600) + if max_in_hour > progress.get("max_commits_per_hour", 0): + progress["max_commits_per_hour"] = max_in_hour + + self._locked_write(self.progress_file, progress) + + def record_session(self) -> None: + """Record a coding session date for streak tracking.""" + progress = self.get_progress() + today = time.strftime("%Y-%m-%d") + dates = progress.get("session_dates", []) + if today not in dates: + dates.append(today) + # Keep only last 30 days + dates = sorted(dates)[-30:] + progress["session_dates"] = dates + + # Calculate current streak + streak = self._calculate_streak(dates) + if streak > progress.get("max_streak_days", 0): + progress["max_streak_days"] = streak + + self._locked_write(self.progress_file, progress) + + def check_achievements(self) -> List[Dict[str, Any]]: + """Check all achievements and return newly unlocked ones. + + Returns: + List of achievement dicts that were just unlocked. + """ + progress = self.get_progress() + unlocked = self.get_unlocked() + unlocked_ids = {a["id"] for a in unlocked} + + newly_unlocked = [] + for defn in ACHIEVEMENT_DEFINITIONS: + if defn["id"] in unlocked_ids: + continue + + metric = defn["metric"] + threshold = defn["threshold"] + + # Get the metric value + if metric == "unique_agents": + value = len(progress.get("unique_agents", [])) + else: + value = progress.get(metric, 0) + + if value >= threshold: + record = { + "id": defn["id"], + "name": defn["name"], + "unlocked_at": time.time(), + "value": value, + } + unlocked.append(record) + newly_unlocked.append(defn) + + if newly_unlocked: + self._locked_write(self.unlocked_file, unlocked) + + return newly_unlocked + + @staticmethod + def _max_commits_in_window(timestamps: List[float], window_secs: int) -> int: + """Find maximum number of commits in any sliding window. + + Args: + timestamps: Sorted list of commit timestamps. + window_secs: Window size in seconds. + + Returns: + Maximum commit count in any window. + """ + if not timestamps: + return 0 + sorted_ts = sorted(timestamps) + max_count = 0 + for i, start in enumerate(sorted_ts): + count = 0 + for ts in sorted_ts[i:]: + if ts - start <= window_secs: + count += 1 + else: + break + max_count = max(max_count, count) + return max_count + + @staticmethod + def _calculate_streak(dates: List[str]) -> int: + """Calculate the current consecutive day streak. + + Args: + dates: Sorted list of date strings (YYYY-MM-DD). + + Returns: + Current streak length in days. + """ + if not dates: + return 0 + + from datetime import datetime, timedelta + + sorted_dates = sorted(set(dates)) + parsed = [datetime.strptime(d, "%Y-%m-%d") for d in sorted_dates] + + # Walk backwards from most recent date + streak = 1 + for i in range(len(parsed) - 1, 0, -1): + diff = (parsed[i] - parsed[i - 1]).days + if diff == 1: + streak += 1 + else: + break + return streak + + def _locked_read(self, filepath: str, default: Any) -> Any: + """Read JSON file with file locking.""" + try: + with open(filepath, "r", encoding="utf-8") as f: + if HAS_FCNTL: + fcntl.flock(f.fileno(), fcntl.LOCK_SH) + return json.load(f) + except (json.JSONDecodeError, OSError): + return default + + def _locked_write(self, filepath: str, data: Any) -> None: + """Write JSON file with file locking.""" + with open(filepath, "w", encoding="utf-8") as f: + if HAS_FCNTL: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + json.dump(data, f, indent=2) + + +def render_achievement_celebration( + achievement: Dict[str, Any], + language: str = "en", +) -> str: + """Render a celebration message for an unlocked achievement. + + Args: + achievement: Achievement definition dict with id, name, icon, face. + language: Language code. + + Returns: + Formatted celebration string with special buddy face. + """ + msgs = CELEBRATION_MESSAGES.get(language, CELEBRATION_MESSAGES["en"]) + icon = achievement.get("icon", "\U0001f3c6") + name = achievement.get("name", "Achievement") + face = achievement.get("face", "\u2605\u203f\u2605") + desc = achievement.get("description", "") + + lines = [ + "", + f"\u2501\u2501 {icon} {msgs['unlocked']} \u2501\u2501\u2501\u2501\u2501\u2501", + f"\u256d\u2501\u2501\u2501\u256e", + f"\u2503 {face} \u2503 {msgs['congrats']}", + f"\u2570\u2501\u2501\u2501\u256f", + f" {icon} {name}", + f" {desc}", + "", + ] + return "\n".join(lines) + + +def render_achievement_badges( + unlocked: List[Dict[str, Any]], + language: str = "en", +) -> str: + """Render badge display area for TUI. + + Args: + unlocked: List of unlocked achievement records (from get_unlocked). + language: Language code. + + Returns: + Formatted badge display string, empty if no badges. + """ + if not unlocked: + return "" + + header = BADGE_HEADERS.get(language, BADGE_HEADERS["en"]) + lines = [f"\u2501\u2501 {header} \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"] + + for record in unlocked: + defn = get_achievement_by_id(record.get("id", "")) + if defn: + icon = defn["icon"] + name = defn["name"] + lines.append(f" {icon} {name}") + else: + name = record.get("name", record.get("id", "?")) + lines.append(f" \U0001f3c6 {name}") + + return "\n".join(lines) diff --git a/packages/claude-code-plugin/hooks/lib/buddy_renderer.py b/packages/claude-code-plugin/hooks/lib/buddy_renderer.py index 7ec2af84..df5ce12d 100644 --- a/packages/claude-code-plugin/hooks/lib/buddy_renderer.py +++ b/packages/claude-code-plugin/hooks/lib/buddy_renderer.py @@ -521,6 +521,29 @@ def render_returning_session( return "\n".join(parts) +def render_badges_section(language: str = "en") -> str: + """Render achievement badges section for session display (#1008). + + Loads unlocked achievements and renders them as a badge area. + + Args: + language: Language code (en, ko, ja, zh, es). + + Returns: + Formatted badge section string, empty if no badges or on error. + """ + try: + from achievement_tracker import AchievementTracker, render_achievement_badges + + tracker = AchievementTracker() + unlocked = tracker.get_unlocked() + if unlocked: + return render_achievement_badges(unlocked, language) + except Exception: + pass + return "" + + def render_session_start( scan: Dict[str, Any], recommendations: List[Dict[str, Any]], @@ -572,4 +595,10 @@ def render_session_start( parts.append(f"\u2501\u2501 {header} \u2501\u2501\u2501\u2501\u2501\u2501") parts.append(render_recommendations(recommendations)) + # Achievement badges section (#1008) + badges = render_badges_section(language) + if badges: + parts.append("") + parts.append(badges) + return "\n".join(parts) diff --git a/packages/claude-code-plugin/hooks/stop.py b/packages/claude-code-plugin/hooks/stop.py index ab48a2a8..b2e3d6c2 100644 --- a/packages/claude-code-plugin/hooks/stop.py +++ b/packages/claude-code-plugin/hooks/stop.py @@ -127,6 +127,39 @@ def handle_stop(data: dict): except Exception: pass # Never block session stop + # Check achievements on session stop (#1008) + try: + from achievement_tracker import ( + AchievementTracker, + render_achievement_celebration, + render_achievement_badges, + ) + + tracker = AchievementTracker() + tracker.record_session() + + # Record agent usage if active + active_agent = os.environ.get("CODINGBUDDY_ACTIVE_AGENT", "") + if active_agent: + tracker.record_agent_usage(active_agent) + + newly_unlocked = tracker.check_achievements() + if newly_unlocked: + for achievement in newly_unlocked: + celebration = render_achievement_celebration( + achievement, language + ) + print(celebration, file=sys.stderr) + + # Show badge summary if any unlocked + all_unlocked = tracker.get_unlocked() + if all_unlocked: + badges = render_achievement_badges(all_unlocked, language) + if badges: + print(badges, file=sys.stderr) + except Exception: + pass # Never block session stop + # Notify on session end (#829) try: _maybe_notify_session_end(summary) diff --git a/packages/claude-code-plugin/hooks/tests/test_achievement_tracker.py b/packages/claude-code-plugin/hooks/tests/test_achievement_tracker.py new file mode 100644 index 00000000..fe07951e --- /dev/null +++ b/packages/claude-code-plugin/hooks/tests/test_achievement_tracker.py @@ -0,0 +1,352 @@ +"""Tests for achievement_tracker module (#1008).""" +import json +import os +import sys +import tempfile +import time + +import pytest + +# Add lib to path +_hooks_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_lib_dir = os.path.join(_hooks_dir, "lib") +if _lib_dir not in sys.path: + sys.path.insert(0, _lib_dir) + +from achievement_tracker import ( + ACHIEVEMENT_DEFINITIONS, + AchievementTracker, + get_achievement_by_id, + get_achievement_definitions, + render_achievement_badges, + render_achievement_celebration, +) + + +@pytest.fixture +def tmp_data_dir(tmp_path): + """Provide a temporary data directory for tests.""" + return str(tmp_path) + + +@pytest.fixture +def tracker(tmp_data_dir): + """Provide an AchievementTracker with temp directory.""" + return AchievementTracker(data_dir=tmp_data_dir) + + +class TestAchievementDefinitions: + """Test achievement definition helpers.""" + + def test_get_all_definitions(self): + defs = get_achievement_definitions() + assert len(defs) == 5 + ids = {d["id"] for d in defs} + assert ids == { + "tdd_champion", + "agent_master", + "quality_guard", + "speed_coder", + "streak", + } + + def test_get_by_id_found(self): + defn = get_achievement_by_id("tdd_champion") + assert defn is not None + assert defn["name"] == "TDD Champion" + assert defn["threshold"] == 100 + + def test_get_by_id_not_found(self): + assert get_achievement_by_id("nonexistent") is None + + def test_definitions_have_required_fields(self): + for defn in ACHIEVEMENT_DEFINITIONS: + assert "id" in defn + assert "name" in defn + assert "description" in defn + assert "metric" in defn + assert "threshold" in defn + assert "icon" in defn + assert "face" in defn + + +class TestAchievementTracker: + """Test AchievementTracker class.""" + + def test_init_creates_directories(self, tmp_data_dir): + tracker = AchievementTracker(data_dir=tmp_data_dir) + assert os.path.isdir(os.path.join(tmp_data_dir, "achievements")) + + def test_init_creates_progress_file(self, tracker, tmp_data_dir): + progress_file = os.path.join(tmp_data_dir, "achievements", "progress.json") + assert os.path.exists(progress_file) + + def test_init_creates_unlocked_file(self, tracker, tmp_data_dir): + unlocked_file = os.path.join(tmp_data_dir, "achievements", "unlocked.json") + assert os.path.exists(unlocked_file) + + def test_default_progress(self, tracker): + progress = tracker.get_progress() + assert progress["tdd_cycles"] == 0 + assert progress["unique_agents"] == [] + assert progress["max_eval_score"] == 0 + assert progress["max_commits_per_hour"] == 0 + + def test_default_unlocked_empty(self, tracker): + assert tracker.get_unlocked() == [] + + +class TestRecordTddCycle: + """Test TDD cycle recording.""" + + def test_record_increments(self, tracker): + tracker.record_tdd_cycle() + assert tracker.get_progress()["tdd_cycles"] == 1 + + def test_record_multiple(self, tracker): + for _ in range(5): + tracker.record_tdd_cycle() + assert tracker.get_progress()["tdd_cycles"] == 5 + + +class TestRecordAgentUsage: + """Test agent usage recording.""" + + def test_record_new_agent(self, tracker): + tracker.record_agent_usage("security-specialist") + agents = tracker.get_progress()["unique_agents"] + assert "security-specialist" in agents + + def test_record_duplicate_agent(self, tracker): + tracker.record_agent_usage("test-engineer") + tracker.record_agent_usage("test-engineer") + agents = tracker.get_progress()["unique_agents"] + assert agents.count("test-engineer") == 1 + + def test_record_multiple_agents(self, tracker): + for name in ["agent-a", "agent-b", "agent-c"]: + tracker.record_agent_usage(name) + agents = tracker.get_progress()["unique_agents"] + assert len(agents) == 3 + + def test_record_empty_name_ignored(self, tracker): + tracker.record_agent_usage("") + assert tracker.get_progress()["unique_agents"] == [] + + +class TestRecordEvalScore: + """Test eval score recording.""" + + def test_record_score(self, tracker): + tracker.record_eval_score(85) + assert tracker.get_progress()["max_eval_score"] == 85 + + def test_keeps_max_score(self, tracker): + tracker.record_eval_score(85) + tracker.record_eval_score(92) + tracker.record_eval_score(80) + assert tracker.get_progress()["max_eval_score"] == 92 + + +class TestRecordCommit: + """Test commit recording.""" + + def test_record_commit(self, tracker): + tracker.record_commit() + progress = tracker.get_progress() + assert len(progress["commit_timestamps"]) == 1 + assert progress["max_commits_per_hour"] == 1 + + def test_multiple_commits_in_hour(self, tracker): + # Manually set timestamps within 1 hour + progress = tracker.get_progress() + now = time.time() + progress["commit_timestamps"] = [now - 600 * i for i in range(5)] + tracker._locked_write(tracker.progress_file, progress) + tracker.record_commit() + progress = tracker.get_progress() + assert progress["max_commits_per_hour"] >= 5 + + +class TestRecordSession: + """Test session date recording.""" + + def test_record_session_adds_today(self, tracker): + tracker.record_session() + dates = tracker.get_progress()["session_dates"] + today = time.strftime("%Y-%m-%d") + assert today in dates + + def test_record_session_no_duplicates(self, tracker): + tracker.record_session() + tracker.record_session() + dates = tracker.get_progress()["session_dates"] + today = time.strftime("%Y-%m-%d") + assert dates.count(today) == 1 + + +class TestCheckAchievements: + """Test achievement unlock detection.""" + + def test_no_achievements_at_start(self, tracker): + newly = tracker.check_achievements() + assert newly == [] + + def test_tdd_champion_unlock(self, tracker): + progress = tracker.get_progress() + progress["tdd_cycles"] = 100 + tracker._locked_write(tracker.progress_file, progress) + + newly = tracker.check_achievements() + assert len(newly) == 1 + assert newly[0]["id"] == "tdd_champion" + + def test_agent_master_unlock(self, tracker): + progress = tracker.get_progress() + progress["unique_agents"] = [f"agent-{i}" for i in range(10)] + tracker._locked_write(tracker.progress_file, progress) + + newly = tracker.check_achievements() + assert any(a["id"] == "agent_master" for a in newly) + + def test_quality_guard_unlock(self, tracker): + progress = tracker.get_progress() + progress["max_eval_score"] = 95 + tracker._locked_write(tracker.progress_file, progress) + + newly = tracker.check_achievements() + assert any(a["id"] == "quality_guard" for a in newly) + + def test_speed_coder_unlock(self, tracker): + progress = tracker.get_progress() + progress["max_commits_per_hour"] = 6 + tracker._locked_write(tracker.progress_file, progress) + + newly = tracker.check_achievements() + assert any(a["id"] == "speed_coder" for a in newly) + + def test_streak_unlock(self, tracker): + progress = tracker.get_progress() + progress["max_streak_days"] = 5 + tracker._locked_write(tracker.progress_file, progress) + + newly = tracker.check_achievements() + assert any(a["id"] == "streak" for a in newly) + + def test_already_unlocked_not_repeated(self, tracker): + progress = tracker.get_progress() + progress["tdd_cycles"] = 200 + tracker._locked_write(tracker.progress_file, progress) + + first = tracker.check_achievements() + assert len(first) == 1 + + second = tracker.check_achievements() + assert second == [] + + def test_unlocked_persisted(self, tracker, tmp_data_dir): + progress = tracker.get_progress() + progress["tdd_cycles"] = 100 + tracker._locked_write(tracker.progress_file, progress) + tracker.check_achievements() + + # New tracker instance reads persisted data + tracker2 = AchievementTracker(data_dir=tmp_data_dir) + unlocked = tracker2.get_unlocked() + assert len(unlocked) == 1 + assert unlocked[0]["id"] == "tdd_champion" + + +class TestStreakCalculation: + """Test streak calculation logic.""" + + def test_empty_dates(self): + assert AchievementTracker._calculate_streak([]) == 0 + + def test_single_date(self): + assert AchievementTracker._calculate_streak(["2026-03-28"]) == 1 + + def test_consecutive_days(self): + dates = ["2026-03-24", "2026-03-25", "2026-03-26", "2026-03-27", "2026-03-28"] + assert AchievementTracker._calculate_streak(dates) == 5 + + def test_broken_streak(self): + dates = ["2026-03-24", "2026-03-25", "2026-03-27", "2026-03-28"] + assert AchievementTracker._calculate_streak(dates) == 2 + + def test_duplicate_dates(self): + dates = ["2026-03-27", "2026-03-27", "2026-03-28"] + assert AchievementTracker._calculate_streak(dates) == 2 + + +class TestMaxCommitsInWindow: + """Test sliding window commit calculation.""" + + def test_empty(self): + assert AchievementTracker._max_commits_in_window([], 3600) == 0 + + def test_single(self): + assert AchievementTracker._max_commits_in_window([100.0], 3600) == 1 + + def test_all_in_window(self): + now = time.time() + ts = [now - 60 * i for i in range(5)] + assert AchievementTracker._max_commits_in_window(ts, 3600) == 5 + + def test_some_outside_window(self): + now = time.time() + ts = [now, now - 1800, now - 3601, now - 7200] + assert AchievementTracker._max_commits_in_window(ts, 3600) == 2 + + +class TestRenderCelebration: + """Test celebration rendering.""" + + def test_render_english(self): + defn = get_achievement_by_id("tdd_champion") + result = render_achievement_celebration(defn, "en") + assert "Achievement Unlocked!" in result + assert "TDD Champion" in result + assert "\u2605\u203f\u2605" in result + + def test_render_korean(self): + defn = get_achievement_by_id("agent_master") + result = render_achievement_celebration(defn, "ko") + assert "업적 달성!" in result + assert "Agent Master" in result + + def test_render_with_defaults(self): + result = render_achievement_celebration({"name": "Test"}) + assert "Test" in result + + +class TestRenderBadges: + """Test badge display rendering.""" + + def test_empty_unlocked(self): + assert render_achievement_badges([]) == "" + + def test_render_single_badge(self): + unlocked = [{"id": "tdd_champion", "name": "TDD Champion"}] + result = render_achievement_badges(unlocked, "en") + assert "Achievements" in result + assert "TDD Champion" in result + + def test_render_multiple_badges(self): + unlocked = [ + {"id": "tdd_champion", "name": "TDD Champion"}, + {"id": "streak", "name": "Streak Master"}, + ] + result = render_achievement_badges(unlocked, "en") + assert "TDD Champion" in result + assert "Streak Master" in result + + def test_render_korean_header(self): + unlocked = [{"id": "tdd_champion", "name": "TDD Champion"}] + result = render_achievement_badges(unlocked, "ko") + assert "업적" in result + + def test_render_unknown_id(self): + unlocked = [{"id": "unknown_xyz", "name": "Mystery"}] + result = render_achievement_badges(unlocked, "en") + assert "Mystery" in result