test_skin_engine.py
1 """Tests for hermes_cli.skin_engine — the data-driven skin/theme system.""" 2 3 import json 4 import os 5 import pytest 6 from pathlib import Path 7 from unittest.mock import patch 8 9 10 @pytest.fixture(autouse=True) 11 def reset_skin_state(): 12 """Reset skin engine state between tests.""" 13 from hermes_cli import skin_engine 14 skin_engine._active_skin = None 15 skin_engine._active_skin_name = "default" 16 yield 17 skin_engine._active_skin = None 18 skin_engine._active_skin_name = "default" 19 20 21 class TestSkinConfig: 22 def test_default_skin_has_required_fields(self): 23 from hermes_cli.skin_engine import load_skin 24 skin = load_skin("default") 25 assert skin.name == "default" 26 assert skin.tool_prefix == "┊" 27 assert "banner_title" in skin.colors 28 assert "banner_border" in skin.colors 29 assert "agent_name" in skin.branding 30 31 def test_get_color_with_fallback(self): 32 from hermes_cli.skin_engine import load_skin 33 skin = load_skin("default") 34 assert skin.get_color("banner_title") == "#FFD700" 35 assert skin.get_color("nonexistent", "#000") == "#000" 36 37 def test_get_branding_with_fallback(self): 38 from hermes_cli.skin_engine import load_skin 39 skin = load_skin("default") 40 assert skin.get_branding("agent_name") == "Hermes Agent" 41 assert skin.get_branding("nonexistent", "fallback") == "fallback" 42 43 def test_get_spinner_wings_empty_for_default(self): 44 from hermes_cli.skin_engine import load_skin 45 skin = load_skin("default") 46 assert skin.get_spinner_wings() == [] 47 48 49 class TestBuiltinSkins: 50 def test_ares_skin_loads(self): 51 from hermes_cli.skin_engine import load_skin 52 skin = load_skin("ares") 53 assert skin.name == "ares" 54 assert skin.tool_prefix == "╎" 55 assert skin.get_color("banner_border") == "#9F1C1C" 56 assert skin.get_color("response_border") == "#C7A96B" 57 assert skin.get_color("session_label") == "#C7A96B" 58 assert skin.get_color("session_border") == "#6E584B" 59 assert skin.get_branding("agent_name") == "Ares Agent" 60 61 def test_ares_has_spinner_customization(self): 62 from hermes_cli.skin_engine import load_skin 63 skin = load_skin("ares") 64 wings = skin.get_spinner_wings() 65 assert len(wings) > 0 66 assert isinstance(wings[0], tuple) 67 assert len(wings[0]) == 2 68 69 def test_mono_skin_loads(self): 70 from hermes_cli.skin_engine import load_skin 71 skin = load_skin("mono") 72 assert skin.name == "mono" 73 assert skin.get_color("banner_title") == "#e6edf3" 74 75 def test_slate_skin_loads(self): 76 from hermes_cli.skin_engine import load_skin 77 skin = load_skin("slate") 78 assert skin.name == "slate" 79 assert skin.get_color("banner_title") == "#7eb8f6" 80 81 def test_daylight_skin_loads(self): 82 from hermes_cli.skin_engine import load_skin 83 84 skin = load_skin("daylight") 85 assert skin.name == "daylight" 86 assert skin.tool_prefix == "│" 87 assert skin.get_color("banner_title") == "#0F172A" 88 assert skin.get_color("status_bar_bg") == "#E5EDF8" 89 assert skin.get_color("voice_status_bg") == "#E5EDF8" 90 assert skin.get_color("completion_menu_bg") == "#F8FAFC" 91 assert skin.get_color("completion_menu_current_bg") == "#DBEAFE" 92 assert skin.get_color("completion_menu_meta_bg") == "#EEF2FF" 93 assert skin.get_color("completion_menu_meta_current_bg") == "#BFDBFE" 94 95 def test_warm_lightmode_skin_loads(self): 96 from hermes_cli.skin_engine import load_skin 97 98 skin = load_skin("warm-lightmode") 99 assert skin.name == "warm-lightmode" 100 assert skin.get_color("banner_text") == "#2C1810" 101 assert skin.get_color("completion_menu_bg") == "#F5EFE0" 102 103 def test_unknown_skin_falls_back_to_default(self): 104 from hermes_cli.skin_engine import load_skin 105 skin = load_skin("nonexistent_skin_xyz") 106 assert skin.name == "default" 107 108 def test_all_builtin_skins_have_complete_colors(self): 109 from hermes_cli.skin_engine import _BUILTIN_SKINS, _build_skin_config 110 required_keys = ["banner_border", "banner_title", "banner_accent", 111 "banner_dim", "banner_text", "ui_accent"] 112 for name, data in _BUILTIN_SKINS.items(): 113 skin = _build_skin_config(data) 114 for key in required_keys: 115 assert key in skin.colors, f"Skin '{name}' missing color '{key}'" 116 117 118 class TestSkinManagement: 119 def test_set_active_skin(self): 120 from hermes_cli.skin_engine import set_active_skin, get_active_skin, get_active_skin_name 121 skin = set_active_skin("ares") 122 assert skin.name == "ares" 123 assert get_active_skin_name() == "ares" 124 assert get_active_skin().name == "ares" 125 126 def test_get_active_skin_defaults(self): 127 from hermes_cli.skin_engine import get_active_skin 128 skin = get_active_skin() 129 assert skin.name == "default" 130 131 def test_list_skins_includes_builtins(self): 132 from hermes_cli.skin_engine import list_skins 133 skins = list_skins() 134 names = [s["name"] for s in skins] 135 assert "default" in names 136 assert "ares" in names 137 assert "mono" in names 138 assert "slate" in names 139 assert "daylight" in names 140 assert "warm-lightmode" in names 141 for s in skins: 142 assert "source" in s 143 assert s["source"] == "builtin" 144 145 def test_init_skin_from_config(self): 146 from hermes_cli.skin_engine import init_skin_from_config, get_active_skin_name 147 init_skin_from_config({"display": {"skin": "ares"}}) 148 assert get_active_skin_name() == "ares" 149 150 def test_init_skin_from_empty_config(self): 151 from hermes_cli.skin_engine import init_skin_from_config, get_active_skin_name 152 init_skin_from_config({}) 153 assert get_active_skin_name() == "default" 154 155 def test_init_skin_from_null_display(self): 156 """display: null should fall back to default, not crash.""" 157 from hermes_cli.skin_engine import init_skin_from_config, get_active_skin_name 158 init_skin_from_config({"display": None}) 159 assert get_active_skin_name() == "default" 160 161 def test_init_skin_from_non_dict_display(self): 162 """display: <non-dict> should fall back to default.""" 163 from hermes_cli.skin_engine import init_skin_from_config, get_active_skin_name 164 init_skin_from_config({"display": "invalid"}) 165 assert get_active_skin_name() == "default" 166 167 init_skin_from_config({"display": 42}) 168 assert get_active_skin_name() == "default" 169 170 init_skin_from_config({"display": []}) 171 assert get_active_skin_name() == "default" 172 173 174 class TestUserSkins: 175 def test_load_user_skin_from_yaml(self, tmp_path, monkeypatch): 176 from hermes_cli.skin_engine import load_skin, _skins_dir 177 # Create a user skin YAML 178 skins_dir = tmp_path / "skins" 179 skins_dir.mkdir() 180 skin_file = skins_dir / "custom.yaml" 181 skin_data = { 182 "name": "custom", 183 "description": "A custom test skin", 184 "colors": {"banner_title": "#FF0000"}, 185 "branding": {"agent_name": "Custom Agent"}, 186 "tool_prefix": "▸", 187 } 188 import yaml 189 skin_file.write_text(yaml.dump(skin_data)) 190 191 # Patch skins dir 192 monkeypatch.setattr("hermes_cli.skin_engine._skins_dir", lambda: skins_dir) 193 194 skin = load_skin("custom") 195 assert skin.name == "custom" 196 assert skin.get_color("banner_title") == "#FF0000" 197 assert skin.get_branding("agent_name") == "Custom Agent" 198 assert skin.tool_prefix == "▸" 199 # Should inherit defaults for unspecified colors 200 assert skin.get_color("banner_border") == "#CD7F32" # from default 201 202 def test_list_skins_includes_user_skins(self, tmp_path, monkeypatch): 203 from hermes_cli.skin_engine import list_skins 204 skins_dir = tmp_path / "skins" 205 skins_dir.mkdir() 206 import yaml 207 (skins_dir / "pirate.yaml").write_text(yaml.dump({ 208 "name": "pirate", 209 "description": "Arr matey", 210 })) 211 monkeypatch.setattr("hermes_cli.skin_engine._skins_dir", lambda: skins_dir) 212 213 skins = list_skins() 214 names = [s["name"] for s in skins] 215 assert "pirate" in names 216 pirate = [s for s in skins if s["name"] == "pirate"][0] 217 assert pirate["source"] == "user" 218 219 220 class TestDisplayIntegration: 221 def test_get_skin_tool_prefix_default(self): 222 from agent.display import get_skin_tool_prefix 223 assert get_skin_tool_prefix() == "┊" 224 225 def test_get_skin_tool_prefix_custom(self): 226 from hermes_cli.skin_engine import set_active_skin 227 from agent.display import get_skin_tool_prefix 228 set_active_skin("ares") 229 assert get_skin_tool_prefix() == "╎" 230 231 def test_tool_message_uses_skin_prefix(self): 232 from hermes_cli.skin_engine import set_active_skin 233 from agent.display import get_cute_tool_message 234 set_active_skin("ares") 235 msg = get_cute_tool_message("terminal", {"command": "ls"}, 0.5) 236 assert msg.startswith("╎") 237 assert "┊" not in msg 238 239 def test_tool_message_default_prefix(self): 240 from agent.display import get_cute_tool_message 241 msg = get_cute_tool_message("terminal", {"command": "ls"}, 0.5) 242 assert msg.startswith("┊") 243 244 245 class TestCliBrandingHelpers: 246 def test_active_prompt_symbol_default(self): 247 from hermes_cli.skin_engine import get_active_prompt_symbol 248 249 assert get_active_prompt_symbol() == "❯ " 250 251 def test_active_prompt_symbol_ares(self): 252 from hermes_cli.skin_engine import set_active_skin, get_active_prompt_symbol 253 254 set_active_skin("ares") 255 assert get_active_prompt_symbol() == "⚔ " 256 257 def test_active_help_header_ares(self): 258 from hermes_cli.skin_engine import set_active_skin, get_active_help_header 259 260 set_active_skin("ares") 261 assert get_active_help_header() == "(⚔) Available Commands" 262 263 def test_active_goodbye_ares(self): 264 from hermes_cli.skin_engine import set_active_skin, get_active_goodbye 265 266 set_active_skin("ares") 267 assert get_active_goodbye() == "Farewell, warrior! ⚔" 268 269 def test_prompt_toolkit_style_overrides_cover_tui_classes(self): 270 from hermes_cli.skin_engine import set_active_skin, get_prompt_toolkit_style_overrides 271 set_active_skin("ares") 272 overrides = get_prompt_toolkit_style_overrides() 273 required = { 274 "input-area", 275 "placeholder", 276 "prompt", 277 "prompt-working", 278 "hint", 279 "status-bar", 280 "status-bar-strong", 281 "status-bar-dim", 282 "status-bar-good", 283 "status-bar-warn", 284 "status-bar-bad", 285 "status-bar-critical", 286 "input-rule", 287 "image-badge", 288 "completion-menu", 289 "completion-menu.completion", 290 "completion-menu.completion.current", 291 "completion-menu.meta.completion", 292 "completion-menu.meta.completion.current", 293 "status-bar", 294 "status-bar-strong", 295 "status-bar-dim", 296 "status-bar-good", 297 "status-bar-warn", 298 "status-bar-bad", 299 "status-bar-critical", 300 "voice-status", 301 "voice-status-recording", 302 "clarify-border", 303 "clarify-title", 304 "clarify-question", 305 "clarify-choice", 306 "clarify-selected", 307 "clarify-active-other", 308 "clarify-countdown", 309 "sudo-prompt", 310 "sudo-border", 311 "sudo-title", 312 "sudo-text", 313 "approval-border", 314 "approval-title", 315 "approval-desc", 316 "approval-cmd", 317 "approval-choice", 318 "approval-selected", 319 } 320 assert required.issubset(overrides.keys()) 321 322 def test_prompt_toolkit_style_overrides_use_skin_colors(self): 323 from hermes_cli.skin_engine import ( 324 set_active_skin, 325 get_active_skin, 326 get_prompt_toolkit_style_overrides, 327 ) 328 329 set_active_skin("ares") 330 skin = get_active_skin() 331 overrides = get_prompt_toolkit_style_overrides() 332 assert overrides["prompt"] == skin.get_color("prompt") 333 assert overrides["input-rule"] == skin.get_color("input_rule") 334 assert overrides["status-bar"] == ( 335 f"bg:{skin.get_color('status_bar_bg')} {skin.get_color('status_bar_text')}" 336 ) 337 assert overrides["status-bar-strong"] == ( 338 f"bg:{skin.get_color('status_bar_bg')} {skin.get_color('status_bar_strong')} bold" 339 ) 340 assert overrides["status-bar-critical"] == ( 341 f"bg:{skin.get_color('status_bar_bg')} {skin.get_color('status_bar_critical')} bold" 342 ) 343 assert overrides["clarify-title"] == f"{skin.get_color('banner_title')} bold" 344 assert overrides["sudo-prompt"] == f"{skin.get_color('ui_error')} bold" 345 assert overrides["approval-title"] == f"{skin.get_color('ui_warn')} bold" 346 347 set_active_skin("daylight") 348 skin = get_active_skin() 349 overrides = get_prompt_toolkit_style_overrides() 350 assert overrides["status-bar"] == f"bg:{skin.get_color('status_bar_bg')} {skin.get_color('banner_text')}" 351 assert overrides["voice-status"] == f"bg:{skin.get_color('voice_status_bg')} {skin.get_color('ui_label')}"