feat: add inserter module with clipboard paste and restore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch, call
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from whisper_local.inserter import Inserter
|
||||||
|
|
||||||
|
|
||||||
|
class TestInserter:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("whisper_local.inserter.asyncio.sleep", new_callable=AsyncMock)
|
||||||
|
@patch("whisper_local.inserter.asyncio.create_subprocess_exec")
|
||||||
|
async def test_insert_text_calls_tools_in_order(self, mock_exec, mock_sleep):
|
||||||
|
# Mock für alle subprocess-Aufrufe
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"alter clipboard", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
mock_exec.return_value = mock_proc
|
||||||
|
|
||||||
|
inserter = Inserter()
|
||||||
|
await inserter.insert("Hallo Welt")
|
||||||
|
|
||||||
|
# Prüfe dass wl-paste, wl-copy, wtype, wl-copy aufgerufen werden
|
||||||
|
calls = mock_exec.call_args_list
|
||||||
|
assert len(calls) == 4
|
||||||
|
assert calls[0][0][0] == "wl-paste" # Clipboard sichern
|
||||||
|
assert calls[1][0] == ("wl-copy", "Hallo Welt") # Text in Clipboard
|
||||||
|
assert calls[2][0][0] == "wtype" # Ctrl+V simulieren
|
||||||
|
assert calls[3][0] == ("wl-copy", "alter clipboard") # Clipboard wiederherstellen
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("whisper_local.inserter.asyncio.create_subprocess_exec")
|
||||||
|
async def test_insert_empty_text_does_nothing(self, mock_exec):
|
||||||
|
inserter = Inserter()
|
||||||
|
await inserter.insert("")
|
||||||
|
|
||||||
|
mock_exec.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("whisper_local.inserter.asyncio.sleep", new_callable=AsyncMock)
|
||||||
|
@patch("whisper_local.inserter.asyncio.create_subprocess_exec")
|
||||||
|
async def test_clipboard_restore_on_paste_failure(self, mock_exec, mock_sleep):
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def mock_create_proc(*args, **kwargs):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
if call_count == 1: # wl-paste
|
||||||
|
mock_proc.communicate.return_value = (b"original", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
elif call_count == 2: # wl-copy
|
||||||
|
mock_proc.communicate.return_value = (b"", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
elif call_count == 3: # wtype — fails
|
||||||
|
mock_proc.communicate.return_value = (b"", b"error")
|
||||||
|
mock_proc.returncode = 1
|
||||||
|
else: # wl-copy restore
|
||||||
|
mock_proc.communicate.return_value = (b"", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
return mock_proc
|
||||||
|
|
||||||
|
mock_exec.side_effect = mock_create_proc
|
||||||
|
|
||||||
|
inserter = Inserter()
|
||||||
|
await inserter.insert("Test")
|
||||||
|
|
||||||
|
# Clipboard muss trotzdem wiederhergestellt werden
|
||||||
|
assert call_count == 4
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Text-Einfügung via Clipboard und wtype."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PASTE_DELAY = 0.2 # Sekunden Wartezeit vor Clipboard-Restore
|
||||||
|
|
||||||
|
|
||||||
|
class Inserter:
|
||||||
|
async def _run(self, *cmd: str) -> tuple[bytes, int]:
|
||||||
|
"""Führt einen Befehl aus, gibt (stdout, returncode) zurück."""
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
logger.warning("Befehl fehlgeschlagen: %s (stderr: %s)", cmd, stderr.decode())
|
||||||
|
return stdout, proc.returncode
|
||||||
|
|
||||||
|
async def insert(self, text: str) -> None:
|
||||||
|
"""Fügt Text ins aktive Fenster ein via Clipboard + Ctrl+V."""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Aktuelle Zwischenablage sichern
|
||||||
|
old_clipboard, _ = await self._run("wl-paste")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 2. Text in Zwischenablage kopieren
|
||||||
|
await self._run("wl-copy", text)
|
||||||
|
|
||||||
|
# 3. Ctrl+V simulieren
|
||||||
|
await self._run("wtype", "-M", "ctrl", "v", "-m", "ctrl")
|
||||||
|
|
||||||
|
# 4. Kurz warten, dann Clipboard wiederherstellen
|
||||||
|
await asyncio.sleep(PASTE_DELAY)
|
||||||
|
finally:
|
||||||
|
# Clipboard immer wiederherstellen
|
||||||
|
await self._run("wl-copy", old_clipboard.decode())
|
||||||
|
|
||||||
|
logger.info("Text eingefügt (%d Zeichen)", len(text))
|
||||||
Reference in New Issue
Block a user