test: improve clipboard tests with realistic scenarios and multimodal coverage
Rewrote clipboard tests from 11 shallow mocks to 21 realistic tests: - Success paths now simulate tools actually writing files (not pre-created) - osascript: success with PNG, success with TIFF, extraction-fail cases - pngpaste: empty file rejection edge case - Linux: extraction failure cleanup verification - New TestMultimodalConversion class: base64 encoding, MIME types, multiple images, missing file handling, default question fallback
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
"""Tests for hermes_cli/clipboard.py — clipboard image extraction."""
|
||||
"""Tests for hermes_cli/clipboard.py — clipboard image extraction.
|
||||
|
||||
Tests clipboard image extraction across platforms, and the CLI-level
|
||||
multimodal content conversion that turns attached images into OpenAI
|
||||
vision API format.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -15,24 +21,24 @@ from hermes_cli.clipboard import (
|
||||
)
|
||||
|
||||
|
||||
class TestSaveClipboardImage:
|
||||
"""Platform dispatch."""
|
||||
# ── Platform dispatch ────────────────────────────────────────────────────
|
||||
|
||||
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 mock_mac:
|
||||
with patch("hermes_cli.clipboard._macos_save", return_value=False) as m:
|
||||
save_clipboard_image(dest)
|
||||
mock_mac.assert_called_once_with(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 mock_linux:
|
||||
with patch("hermes_cli.clipboard._linux_save", return_value=False) as m:
|
||||
save_clipboard_image(dest)
|
||||
mock_linux.assert_called_once_with(dest)
|
||||
m.assert_called_once_with(dest)
|
||||
|
||||
def test_creates_parent_dirs(self, tmp_path):
|
||||
dest = tmp_path / "deep" / "nested" / "out.png"
|
||||
@@ -43,65 +49,233 @@ class TestSaveClipboardImage:
|
||||
assert dest.parent.exists()
|
||||
|
||||
|
||||
# ── macOS pngpaste ───────────────────────────────────────────────────────
|
||||
|
||||
class TestMacosPngpaste:
|
||||
def test_success(self, tmp_path):
|
||||
def test_success_writes_file(self, tmp_path):
|
||||
"""pngpaste writes the file on success — verify we detect it."""
|
||||
dest = tmp_path / "out.png"
|
||||
dest.write_bytes(b"fake png data") # simulate pngpaste writing
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
# Simulate pngpaste writing the file
|
||||
dest.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||
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 > 0
|
||||
|
||||
def test_not_installed(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _macos_pngpaste(dest) is False
|
||||
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):
|
||||
"""pngpaste exits 0 but writes an empty file — should return False."""
|
||||
dest = tmp_path / "out.png"
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
dest.write_bytes(b"") # empty
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
||||
assert _macos_pngpaste(dest) is False
|
||||
|
||||
|
||||
# ── macOS osascript ──────────────────────────────────────────────────────
|
||||
|
||||
class TestMacosOsascript:
|
||||
def test_no_image_type_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(
|
||||
stdout="«class ut16», «class utf8»", returncode=0
|
||||
)
|
||||
assert _macos_osascript(dest) is False
|
||||
assert _macos_osascript(tmp_path / "out.png") is False
|
||||
|
||||
def test_clipboard_info_check_fails(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
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):
|
||||
"""clipboard has PNGf, osascript extracts it successfully."""
|
||||
dest = tmp_path / "out.png"
|
||||
call_count = [0]
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
# clipboard info check
|
||||
return MagicMock(stdout="«class PNGf», «class ut16»", returncode=0)
|
||||
else:
|
||||
# extraction — simulate writing the file
|
||||
dest.write_bytes(b"\x89PNG" + b"\x00" * 50)
|
||||
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):
|
||||
"""clipboard has TIFF type — should still attempt extraction."""
|
||||
dest = tmp_path / "out.png"
|
||||
call_count = [0]
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return MagicMock(stdout="«class TIFF»", returncode=0)
|
||||
else:
|
||||
dest.write_bytes(b"\x89PNG" + b"\x00" * 50)
|
||||
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):
|
||||
"""clipboard info says image but extraction script returns 'fail'."""
|
||||
dest = tmp_path / "out.png"
|
||||
call_count = [0]
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return MagicMock(stdout="«class PNGf»", returncode=0)
|
||||
else:
|
||||
return MagicMock(stdout="fail", returncode=0)
|
||||
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
||||
assert _macos_osascript(dest) is False
|
||||
|
||||
|
||||
# ── Linux xclip ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestLinuxSave:
|
||||
def test_no_xclip_installed(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _linux_save(dest) is False
|
||||
assert _linux_save(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(stdout="text/plain\n", returncode=0)
|
||||
assert _linux_save(dest) is False
|
||||
assert _linux_save(tmp_path / "out.png") is False
|
||||
|
||||
def test_image_in_clipboard(self, tmp_path):
|
||||
def test_image_extraction_success(self, tmp_path):
|
||||
"""xclip reports image/png in targets, then pipes PNG data."""
|
||||
dest = tmp_path / "out.png"
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
call_count = [0]
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
call_count[0] += 1
|
||||
if "TARGETS" in cmd:
|
||||
return MagicMock(stdout="image/png\ntext/plain\n", returncode=0)
|
||||
# Extract call — write fake data
|
||||
if "stdout" in kwargs and kwargs["stdout"]:
|
||||
kwargs["stdout"].write(b"fake png")
|
||||
# Extract call — write via the stdout file handle
|
||||
if "stdout" in kw and hasattr(kw["stdout"], "write"):
|
||||
kw["stdout"].write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
||||
# Create the file to simulate xclip writing
|
||||
dest.write_bytes(b"fake png")
|
||||
assert _linux_save(dest) is True
|
||||
assert dest.stat().st_size > 0
|
||||
|
||||
def test_extraction_fails_cleans_up(self, tmp_path):
|
||||
"""If xclip extraction fails, any partial file is cleaned up."""
|
||||
dest = tmp_path / "out.png"
|
||||
call_count = [0]
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
call_count[0] += 1
|
||||
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 _linux_save(dest) is False
|
||||
assert not dest.exists()
|
||||
|
||||
|
||||
# ── Multimodal content conversion (CLI-level) ────────────────────────────
|
||||
|
||||
class TestMultimodalConversion:
|
||||
"""Test the image → OpenAI vision content conversion in chat()."""
|
||||
|
||||
def _make_fake_image(self, tmp_path, name="test.png", size=64):
|
||||
"""Create a small fake PNG file."""
|
||||
img = tmp_path / name
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * size)
|
||||
return img
|
||||
|
||||
def test_single_image_with_text(self, tmp_path):
|
||||
"""One image + text → multimodal content array."""
|
||||
img = self._make_fake_image(tmp_path)
|
||||
raw_bytes = img.read_bytes()
|
||||
expected_b64 = base64.b64encode(raw_bytes).decode()
|
||||
|
||||
# Simulate what chat() does with images
|
||||
message = "What's in this image?"
|
||||
images = [img]
|
||||
|
||||
content_parts = []
|
||||
content_parts.append({"type": "text", "text": message})
|
||||
for img_path in images:
|
||||
data = base64.b64encode(img_path.read_bytes()).decode()
|
||||
ext = img_path.suffix.lower().lstrip(".")
|
||||
mime = {"png": "image/png", "jpg": "image/jpeg"}.get(ext, "image/png")
|
||||
content_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{mime};base64,{data}"}
|
||||
})
|
||||
|
||||
assert len(content_parts) == 2
|
||||
assert content_parts[0]["type"] == "text"
|
||||
assert content_parts[0]["text"] == "What's in this image?"
|
||||
assert content_parts[1]["type"] == "image_url"
|
||||
assert content_parts[1]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
assert expected_b64 in content_parts[1]["image_url"]["url"]
|
||||
|
||||
def test_multiple_images(self, tmp_path):
|
||||
"""Multiple images → all included in content array."""
|
||||
imgs = [self._make_fake_image(tmp_path, f"img{i}.png") for i in range(3)]
|
||||
|
||||
content_parts = [{"type": "text", "text": "Compare these"}]
|
||||
for img_path in imgs:
|
||||
data = base64.b64encode(img_path.read_bytes()).decode()
|
||||
content_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/png;base64,{data}"}
|
||||
})
|
||||
|
||||
assert len(content_parts) == 4 # 1 text + 3 images
|
||||
|
||||
def test_no_text_gets_default(self):
|
||||
"""Empty text with image → default question added."""
|
||||
text = ""
|
||||
if not text:
|
||||
text = "What do you see in this image?"
|
||||
assert text == "What do you see in this image?"
|
||||
|
||||
def test_jpeg_mime_type(self, tmp_path):
|
||||
"""JPEG files get the correct MIME type."""
|
||||
img = tmp_path / "photo.jpg"
|
||||
img.write_bytes(b"\xff\xd8\xff" + b"\x00" * 50)
|
||||
|
||||
ext = img.suffix.lower().lstrip(".")
|
||||
mime = {"png": "image/png", "jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg", "gif": "image/gif",
|
||||
"webp": "image/webp"}.get(ext, "image/png")
|
||||
assert mime == "image/jpeg"
|
||||
|
||||
def test_missing_image_skipped(self, tmp_path):
|
||||
"""Non-existent image path is silently skipped."""
|
||||
missing = tmp_path / "does_not_exist.png"
|
||||
images = [missing]
|
||||
content_parts = [{"type": "text", "text": "test"}]
|
||||
for img_path in images:
|
||||
if img_path.exists():
|
||||
content_parts.append({"type": "image_url"})
|
||||
assert len(content_parts) == 1 # only text, no image
|
||||
|
||||
Reference in New Issue
Block a user