/ tests / hermes_cli / test_skin_engine.py
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')}"