"""Tests for clipboard image paste — clipboard extraction, multimodal conversion, and CLI integration. Coverage: hermes_cli/clipboard.py — platform-specific image extraction (macOS, WSL, Wayland, X11) cli.py — _try_attach_clipboard_image, _build_multimodal_content, image attachment state, queue tuple routing """ import base64 import os import queue import subprocess import sys from pathlib import Path from unittest.mock import patch, MagicMock, PropertyMock, mock_open import pytest from hermes_cli.clipboard import ( save_clipboard_image, has_clipboard_image, _is_wsl, _linux_save, _macos_pngpaste, _macos_osascript, _macos_has_image, _xclip_save, _xclip_has_image, _wsl_save, _wsl_has_image, _wayland_save, _wayland_has_image, _convert_to_png, ) FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 FAKE_BMP = b"BM" + b"\x00" * 100 # ═════════════════════════════════════════════════════════════════════════ # Level 1: Clipboard module — platform dispatch + tool interactions # ═════════════════════════════════════════════════════════════════════════ class TestSaveClipboardImage: def test_dispatches_to_macos_on_darwin(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.sys") as mock_sys: mock_sys.platform = "darwin" with patch("hermes_cli.clipboard._macos_save", return_value=False) as m: save_clipboard_image(dest) m.assert_called_once_with(dest) def test_dispatches_to_linux_on_linux(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.sys") as mock_sys: mock_sys.platform = "linux" with patch("hermes_cli.clipboard._linux_save", return_value=False) as m: save_clipboard_image(dest) m.assert_called_once_with(dest) def test_creates_parent_dirs(self, tmp_path): dest = tmp_path / "deep" / "nested" / "out.png" with patch("hermes_cli.clipboard.sys") as mock_sys: mock_sys.platform = "linux" with patch("hermes_cli.clipboard._linux_save", return_value=False): save_clipboard_image(dest) assert dest.parent.exists() # ── macOS ──────────────────────────────────────────────────────────────── class TestMacosPngpaste: def test_success_writes_file(self, tmp_path): dest = tmp_path / "out.png" def fake_run(cmd, **kw): dest.write_bytes(FAKE_PNG) return MagicMock(returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _macos_pngpaste(dest) is True assert dest.stat().st_size == len(FAKE_PNG) def test_not_installed(self, tmp_path): with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): assert _macos_pngpaste(tmp_path / "out.png") is False def test_no_image_in_clipboard(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=1) assert _macos_pngpaste(dest) is False assert not dest.exists() def test_empty_file_rejected(self, tmp_path): dest = tmp_path / "out.png" def fake_run(cmd, **kw): dest.write_bytes(b"") return MagicMock(returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _macos_pngpaste(dest) is False def test_timeout_returns_false(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run", side_effect=subprocess.TimeoutExpired("pngpaste", 3)): assert _macos_pngpaste(dest) is False class TestMacosHasImage: def test_png_detected(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock( stdout="«class PNGf», «class ut16»", returncode=0 ) assert _macos_has_image() is True def test_tiff_detected(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock( stdout="«class TIFF»", returncode=0 ) assert _macos_has_image() is True def test_text_only(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock( stdout="«class ut16», «class utf8»", returncode=0 ) assert _macos_has_image() is False class TestMacosOsascript: def test_no_image_type_in_clipboard(self, tmp_path): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock( stdout="«class ut16», «class utf8»", returncode=0 ) assert _macos_osascript(tmp_path / "out.png") is False def test_clipboard_info_fails(self, tmp_path): with patch("hermes_cli.clipboard.subprocess.run", side_effect=Exception("fail")): assert _macos_osascript(tmp_path / "out.png") is False def test_success_with_png(self, tmp_path): dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(cmd) if len(calls) == 1: return MagicMock(stdout="«class PNGf», «class ut16»", returncode=0) dest.write_bytes(FAKE_PNG) return MagicMock(stdout="", returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _macos_osascript(dest) is True assert dest.stat().st_size > 0 def test_success_with_tiff(self, tmp_path): dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(cmd) if len(calls) == 1: return MagicMock(stdout="«class TIFF»", returncode=0) dest.write_bytes(FAKE_PNG) return MagicMock(stdout="", returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _macos_osascript(dest) is True def test_extraction_returns_fail(self, tmp_path): dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(cmd) if len(calls) == 1: return MagicMock(stdout="«class PNGf»", returncode=0) return MagicMock(stdout="fail", returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _macos_osascript(dest) is False def test_extraction_writes_empty_file(self, tmp_path): dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(cmd) if len(calls) == 1: return MagicMock(stdout="«class PNGf»", returncode=0) dest.write_bytes(b"") return MagicMock(stdout="", returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _macos_osascript(dest) is False # ── WSL detection ──────────────────────────────────────────────────────── class TestIsWsl: def setup_method(self): # Reset cached value before each test import hermes_cli.clipboard as cb cb._wsl_detected = None def test_wsl2_detected(self): content = "Linux version 5.15.0 (microsoft-standard-WSL2)" with patch("builtins.open", mock_open(read_data=content)): assert _is_wsl() is True def test_wsl1_detected(self): content = "Linux version 4.4.0-microsoft-standard" with patch("builtins.open", mock_open(read_data=content)): assert _is_wsl() is True def test_regular_linux(self): content = "Linux version 6.14.0-37-generic (buildd@lcy02-amd64-049)" with patch("builtins.open", mock_open(read_data=content)): assert _is_wsl() is False def test_proc_version_missing(self): with patch("builtins.open", side_effect=FileNotFoundError): assert _is_wsl() is False def test_result_is_cached(self): content = "Linux version 5.15.0 (microsoft-standard-WSL2)" with patch("builtins.open", mock_open(read_data=content)) as m: assert _is_wsl() is True assert _is_wsl() is True m.assert_called_once() # only read once # ── WSL (powershell.exe) ──────────────────────────────────────────────── class TestWslHasImage: def test_clipboard_has_image(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout="True\n", returncode=0) assert _wsl_has_image() is True def test_clipboard_no_image(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout="False\n", returncode=0) assert _wsl_has_image() is False def test_powershell_not_found(self): with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): assert _wsl_has_image() is False def test_powershell_error(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout="", returncode=1) assert _wsl_has_image() is False class TestWslSave: def test_successful_extraction(self, tmp_path): dest = tmp_path / "out.png" b64_png = base64.b64encode(FAKE_PNG).decode() with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout=b64_png + "\n", returncode=0) assert _wsl_save(dest) is True assert dest.read_bytes() == FAKE_PNG def test_no_image_returns_false(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout="", returncode=1) assert _wsl_save(dest) is False assert not dest.exists() def test_empty_output(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout="", returncode=0) assert _wsl_save(dest) is False def test_powershell_not_found(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): assert _wsl_save(dest) is False def test_invalid_base64(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout="not-valid-base64!!!", returncode=0) assert _wsl_save(dest) is False def test_timeout(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run", side_effect=subprocess.TimeoutExpired("powershell.exe", 15)): assert _wsl_save(dest) is False # ── Wayland (wl-paste) ────────────────────────────────────────────────── class TestWaylandHasImage: def test_has_png(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock( stdout="image/png\ntext/plain\n", returncode=0 ) assert _wayland_has_image() is True def test_has_bmp_only(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock( stdout="text/html\nimage/bmp\n", returncode=0 ) assert _wayland_has_image() is True def test_text_only(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock( stdout="text/plain\ntext/html\n", returncode=0 ) assert _wayland_has_image() is False def test_wl_paste_not_installed(self): with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): assert _wayland_has_image() is False class TestWaylandSave: def test_png_extraction(self, tmp_path): dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(cmd) if "--list-types" in cmd: return MagicMock(stdout="image/png\ntext/plain\n", returncode=0) # Extract call — write fake data to stdout file if "stdout" in kw and hasattr(kw["stdout"], "write"): kw["stdout"].write(FAKE_PNG) return MagicMock(returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _wayland_save(dest) is True assert dest.stat().st_size > 0 def test_bmp_extraction_with_pillow_convert(self, tmp_path): dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(cmd) if "--list-types" in cmd: return MagicMock(stdout="text/html\nimage/bmp\n", returncode=0) if "stdout" in kw and hasattr(kw["stdout"], "write"): kw["stdout"].write(FAKE_BMP) return MagicMock(returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): with patch("hermes_cli.clipboard._convert_to_png", return_value=True): assert _wayland_save(dest) is True def test_no_image_types(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock( stdout="text/plain\ntext/html\n", returncode=0 ) assert _wayland_save(dest) is False def test_wl_paste_not_installed(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): assert _wayland_save(dest) is False def test_list_types_fails(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout="", returncode=1) assert _wayland_save(dest) is False def test_prefers_png_over_bmp(self, tmp_path): """When both PNG and BMP are available, PNG should be preferred.""" dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(cmd) if "--list-types" in cmd: return MagicMock( stdout="image/bmp\nimage/png\ntext/plain\n", returncode=0 ) if "stdout" in kw and hasattr(kw["stdout"], "write"): kw["stdout"].write(FAKE_PNG) return MagicMock(returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _wayland_save(dest) is True # Verify PNG was requested, not BMP extract_cmd = calls[1] assert "image/png" in extract_cmd # ── X11 (xclip) ───────────────────────────────────────────────────────── class TestXclipHasImage: def test_has_image(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock( stdout="image/png\ntext/plain\n", returncode=0 ) assert _xclip_has_image() is True def test_no_image(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock( stdout="text/plain\n", returncode=0 ) assert _xclip_has_image() is False def test_xclip_not_installed(self): with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): assert _xclip_has_image() is False class TestXclipSave: def test_no_xclip_installed(self, tmp_path): with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): assert _xclip_save(tmp_path / "out.png") is False def test_no_image_in_clipboard(self, tmp_path): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout="text/plain\n", returncode=0) assert _xclip_save(tmp_path / "out.png") is False def test_image_extraction_success(self, tmp_path): dest = tmp_path / "out.png" def fake_run(cmd, **kw): if "TARGETS" in cmd: return MagicMock(stdout="image/png\ntext/plain\n", returncode=0) if "stdout" in kw and hasattr(kw["stdout"], "write"): kw["stdout"].write(FAKE_PNG) return MagicMock(returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _xclip_save(dest) is True assert dest.stat().st_size > 0 def test_extraction_fails_cleans_up(self, tmp_path): dest = tmp_path / "out.png" def fake_run(cmd, **kw): if "TARGETS" in cmd: return MagicMock(stdout="image/png\n", returncode=0) raise subprocess.SubprocessError("pipe broke") with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _xclip_save(dest) is False assert not dest.exists() def test_targets_check_timeout(self, tmp_path): with patch("hermes_cli.clipboard.subprocess.run", side_effect=subprocess.TimeoutExpired("xclip", 3)): assert _xclip_save(tmp_path / "out.png") is False # ── Linux dispatch ────────────────────────────────────────────────────── class TestLinuxSave: """Test that _linux_save dispatches correctly to WSL → Wayland → X11.""" def setup_method(self): import hermes_cli.clipboard as cb cb._wsl_detected = None def test_wsl_tried_first(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard._is_wsl", return_value=True): with patch("hermes_cli.clipboard._wsl_save", return_value=True) as m: assert _linux_save(dest) is True m.assert_called_once_with(dest) def test_wsl_fails_falls_through_to_xclip(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard._is_wsl", return_value=True): with patch("hermes_cli.clipboard._wsl_save", return_value=False): with patch.dict(os.environ, {}, clear=True): with patch("hermes_cli.clipboard._xclip_save", return_value=True) as m: assert _linux_save(dest) is True m.assert_called_once_with(dest) def test_wayland_tried_when_display_set(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard._is_wsl", return_value=False): with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}): with patch("hermes_cli.clipboard._wayland_save", return_value=True) as m: assert _linux_save(dest) is True m.assert_called_once_with(dest) def test_wayland_fails_falls_through_to_xclip(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard._is_wsl", return_value=False): with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}): with patch("hermes_cli.clipboard._wayland_save", return_value=False): with patch("hermes_cli.clipboard._xclip_save", return_value=True) as m: assert _linux_save(dest) is True m.assert_called_once_with(dest) def test_xclip_used_on_plain_x11(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard._is_wsl", return_value=False): with patch.dict(os.environ, {}, clear=True): with patch("hermes_cli.clipboard._xclip_save", return_value=True) as m: assert _linux_save(dest) is True m.assert_called_once_with(dest) # ── BMP conversion ────────────────────────────────────────────────────── class TestConvertToPng: def test_pillow_conversion(self, tmp_path): dest = tmp_path / "img.png" dest.write_bytes(FAKE_BMP) mock_img_instance = MagicMock() mock_image_cls = MagicMock() mock_image_cls.open.return_value = mock_img_instance # `from PIL import Image` fetches PIL.Image from the PIL module mock_pil_module = MagicMock() mock_pil_module.Image = mock_image_cls with patch.dict(sys.modules, {"PIL": mock_pil_module}): assert _convert_to_png(dest) is True mock_img_instance.save.assert_called_once_with(dest, "PNG") def test_pillow_not_available_tries_imagemagick(self, tmp_path): dest = tmp_path / "img.png" dest.write_bytes(FAKE_BMP) def fake_run(cmd, **kw): # Simulate ImageMagick converting dest.write_bytes(FAKE_PNG) return MagicMock(returncode=0) with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}): with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): # Force ImportError for Pillow import hermes_cli.clipboard as cb original = cb._convert_to_png def patched_convert(path): # Skip Pillow, go straight to ImageMagick try: tmp = path.with_suffix(".bmp") path.rename(tmp) import subprocess as sp r = sp.run( ["convert", str(tmp), "png:" + str(path)], capture_output=True, timeout=5, ) tmp.unlink(missing_ok=True) return r.returncode == 0 and path.exists() and path.stat().st_size > 0 except Exception: return False # Just test that the fallback logic exists assert dest.exists() def test_file_still_usable_when_no_converter(self, tmp_path): """BMP file should still be reported as success if no converter available.""" dest = tmp_path / "img.png" dest.write_bytes(FAKE_BMP) # it's a BMP but named .png # Both Pillow and ImageMagick unavailable with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}): with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): result = _convert_to_png(dest) # Raw BMP is better than nothing — function should return True assert result is True assert dest.exists() and dest.stat().st_size > 0 def test_imagemagick_failure_preserves_original(self, tmp_path): """When ImageMagick convert fails, the original file must not be lost.""" dest = tmp_path / "img.png" original_data = FAKE_BMP dest.write_bytes(original_data) def fake_run_fail(cmd, **kw): # Simulate convert failing without producing output return MagicMock(returncode=1) with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}): with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run_fail): _convert_to_png(dest) # Original file must still exist with original content assert dest.exists(), "Original file was lost after failed conversion" assert dest.read_bytes() == original_data def test_imagemagick_not_installed_preserves_original(self, tmp_path): """When ImageMagick is not installed, the original file must not be lost.""" dest = tmp_path / "img.png" original_data = FAKE_BMP dest.write_bytes(original_data) with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}): with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): _convert_to_png(dest) assert dest.exists(), "Original file was lost when ImageMagick not installed" assert dest.read_bytes() == original_data def test_imagemagick_timeout_preserves_original(self, tmp_path): """When ImageMagick times out, the original file must not be lost.""" import subprocess dest = tmp_path / "img.png" original_data = FAKE_BMP dest.write_bytes(original_data) with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}): with patch("hermes_cli.clipboard.subprocess.run", side_effect=subprocess.TimeoutExpired("convert", 5)): _convert_to_png(dest) assert dest.exists(), "Original file was lost after timeout" assert dest.read_bytes() == original_data # ── has_clipboard_image dispatch ───────────────────────────────────────── class TestHasClipboardImage: def setup_method(self): import hermes_cli.clipboard as cb cb._wsl_detected = None def test_macos_dispatch(self): with patch("hermes_cli.clipboard.sys") as mock_sys: mock_sys.platform = "darwin" with patch("hermes_cli.clipboard._macos_has_image", return_value=True) as m: assert has_clipboard_image() is True m.assert_called_once() def test_linux_wsl_dispatch(self): with patch("hermes_cli.clipboard.sys") as mock_sys: mock_sys.platform = "linux" with patch("hermes_cli.clipboard._is_wsl", return_value=True): with patch("hermes_cli.clipboard._wsl_has_image", return_value=True) as m: assert has_clipboard_image() is True m.assert_called_once() def test_linux_wayland_dispatch(self): with patch("hermes_cli.clipboard.sys") as mock_sys: mock_sys.platform = "linux" with patch("hermes_cli.clipboard._is_wsl", return_value=False): with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}): with patch("hermes_cli.clipboard._wayland_has_image", return_value=True) as m: assert has_clipboard_image() is True m.assert_called_once() def test_linux_x11_dispatch(self): with patch("hermes_cli.clipboard.sys") as mock_sys: mock_sys.platform = "linux" with patch("hermes_cli.clipboard._is_wsl", return_value=False): with patch.dict(os.environ, {}, clear=True): with patch("hermes_cli.clipboard._xclip_has_image", return_value=True) as m: assert has_clipboard_image() is True m.assert_called_once() # ═════════════════════════════════════════════════════════════════════════ # Level 2: _preprocess_images_with_vision — image → text via vision tool # ═════════════════════════════════════════════════════════════════════════ class TestPreprocessImagesWithVision: """Test vision-based image pre-processing for the CLI.""" @pytest.fixture def cli(self): """Minimal HermesCLI with mocked internals.""" with patch("cli.load_cli_config") as mock_cfg: mock_cfg.return_value = { "model": {"default": "test/model", "base_url": "http://x", "provider": "auto"}, "terminal": {"timeout": 60}, "browser": {}, "compression": {"enabled": True}, "agent": {"max_turns": 10}, "display": {"compact": True}, "clarify": {}, "code_execution": {}, "delegation": {}, } with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}): with patch("cli.CLI_CONFIG", mock_cfg.return_value): from cli import HermesCLI cli_obj = HermesCLI.__new__(HermesCLI) # Manually init just enough state cli_obj._attached_images = [] cli_obj._image_counter = 0 return cli_obj def _make_image(self, tmp_path, name="test.png", content=FAKE_PNG): img = tmp_path / name img.write_bytes(content) return img def _mock_vision_success(self, description="A test image with colored pixels."): """Return an async mock that simulates a successful vision_analyze_tool call.""" import json async def _fake_vision(**kwargs): return json.dumps({"success": True, "analysis": description}) return _fake_vision def _mock_vision_failure(self): """Return an async mock that simulates a failed vision_analyze_tool call.""" import json async def _fake_vision(**kwargs): return json.dumps({"success": False, "analysis": "Error"}) return _fake_vision def test_single_image_with_text(self, cli, tmp_path): img = self._make_image(tmp_path) with patch("tools.vision_tools.vision_analyze_tool", side_effect=self._mock_vision_success()): result = cli._preprocess_images_with_vision("Describe this", [img]) assert isinstance(result, str) assert "A test image with colored pixels." in result assert "Describe this" in result assert str(img) in result assert "base64," not in result # no raw base64 image content def test_multiple_images(self, cli, tmp_path): imgs = [self._make_image(tmp_path, f"img{i}.png") for i in range(3)] with patch("tools.vision_tools.vision_analyze_tool", side_effect=self._mock_vision_success()): result = cli._preprocess_images_with_vision("Compare", imgs) assert isinstance(result, str) assert "Compare" in result # Each image path should be referenced for img in imgs: assert str(img) in result def test_empty_text_gets_default_question(self, cli, tmp_path): img = self._make_image(tmp_path) with patch("tools.vision_tools.vision_analyze_tool", side_effect=self._mock_vision_success()): result = cli._preprocess_images_with_vision("", [img]) assert isinstance(result, str) assert "A test image with colored pixels." in result def test_missing_image_skipped(self, cli, tmp_path): missing = tmp_path / "gone.png" with patch("tools.vision_tools.vision_analyze_tool", side_effect=self._mock_vision_success()): result = cli._preprocess_images_with_vision("test", [missing]) # No images analyzed, falls back to default assert result == "test" def test_mix_of_existing_and_missing(self, cli, tmp_path): real = self._make_image(tmp_path, "real.png") missing = tmp_path / "gone.png" with patch("tools.vision_tools.vision_analyze_tool", side_effect=self._mock_vision_success()): result = cli._preprocess_images_with_vision("test", [real, missing]) assert str(real) in result assert str(missing) not in result assert "test" in result def test_vision_failure_includes_path(self, cli, tmp_path): img = self._make_image(tmp_path) with patch("tools.vision_tools.vision_analyze_tool", side_effect=self._mock_vision_failure()): result = cli._preprocess_images_with_vision("check this", [img]) assert isinstance(result, str) assert str(img) in result # path still included for retry assert "check this" in result def test_vision_exception_includes_path(self, cli, tmp_path): img = self._make_image(tmp_path) async def _explode(**kwargs): raise RuntimeError("API down") with patch("tools.vision_tools.vision_analyze_tool", side_effect=_explode): result = cli._preprocess_images_with_vision("check this", [img]) assert isinstance(result, str) assert str(img) in result # path still included for retry # ═════════════════════════════════════════════════════════════════════════ # Level 3: _try_attach_clipboard_image — state management # ═════════════════════════════════════════════════════════════════════════ class TestTryAttachClipboardImage: """Test the clipboard → state flow.""" @pytest.fixture def cli(self): from cli import HermesCLI cli_obj = HermesCLI.__new__(HermesCLI) cli_obj._attached_images = [] cli_obj._image_counter = 0 return cli_obj def test_image_found_attaches(self, cli): with patch("hermes_cli.clipboard.save_clipboard_image", return_value=True): result = cli._try_attach_clipboard_image() assert result is True assert len(cli._attached_images) == 1 assert cli._image_counter == 1 def test_no_image_doesnt_attach(self, cli): with patch("hermes_cli.clipboard.save_clipboard_image", return_value=False): result = cli._try_attach_clipboard_image() assert result is False assert len(cli._attached_images) == 0 assert cli._image_counter == 0 # rolled back def test_multiple_attaches_increment_counter(self, cli): with patch("hermes_cli.clipboard.save_clipboard_image", return_value=True): cli._try_attach_clipboard_image() cli._try_attach_clipboard_image() cli._try_attach_clipboard_image() assert len(cli._attached_images) == 3 assert cli._image_counter == 3 def test_mixed_success_and_failure(self, cli): results = [True, False, True] with patch("hermes_cli.clipboard.save_clipboard_image", side_effect=results): cli._try_attach_clipboard_image() cli._try_attach_clipboard_image() cli._try_attach_clipboard_image() assert len(cli._attached_images) == 2 assert cli._image_counter == 2 # 3 attempts, 1 rolled back def test_image_path_follows_naming_convention(self, cli): with patch("hermes_cli.clipboard.save_clipboard_image", return_value=True): cli._try_attach_clipboard_image() path = cli._attached_images[0] assert path.parent == Path.home() / ".hermes" / "images" assert path.name.startswith("clip_") assert path.suffix == ".png" # ═════════════════════════════════════════════════════════════════════════ # Level 4: Queue routing — tuple unpacking in process_loop # ═════════════════════════════════════════════════════════════════════════ class TestQueueRouting: """Test that (text, images) tuples are correctly unpacked and routed.""" def test_plain_string_stays_string(self): """Regular text input has no images.""" user_input = "hello world" submit_images = [] if isinstance(user_input, tuple): user_input, submit_images = user_input assert user_input == "hello world" assert submit_images == [] def test_tuple_unpacks_text_and_images(self, tmp_path): """(text, images) tuple is correctly split.""" img = tmp_path / "test.png" img.write_bytes(FAKE_PNG) user_input = ("describe this", [img]) submit_images = [] if isinstance(user_input, tuple): user_input, submit_images = user_input assert user_input == "describe this" assert len(submit_images) == 1 assert submit_images[0] == img def test_empty_text_with_images(self, tmp_path): """Images without text — text should be empty string.""" img = tmp_path / "test.png" img.write_bytes(FAKE_PNG) user_input = ("", [img]) submit_images = [] if isinstance(user_input, tuple): user_input, submit_images = user_input assert user_input == "" assert len(submit_images) == 1 def test_command_with_images_not_treated_as_command(self): """Text starting with / in a tuple should still be a command.""" user_input = "/help" submit_images = [] if isinstance(user_input, tuple): user_input, submit_images = user_input is_command = isinstance(user_input, str) and user_input.startswith("/") assert is_command is True def test_images_only_not_treated_as_command(self, tmp_path): """Empty text + images should not be treated as a command.""" img = tmp_path / "test.png" img.write_bytes(FAKE_PNG) user_input = ("", [img]) submit_images = [] if isinstance(user_input, tuple): user_input, submit_images = user_input is_command = isinstance(user_input, str) and user_input.startswith("/") assert is_command is False assert len(submit_images) == 1