refactor: convert inserter module to package with wayland backend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+11
-18
@@ -1,15 +1,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import sys
|
||||||
from unittest.mock import AsyncMock, patch, call
|
from unittest.mock import AsyncMock, patch, call
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from whisper_local.inserter import Inserter
|
from whisper_local.inserter._wayland import WaylandInserter
|
||||||
|
|
||||||
|
|
||||||
class TestInserter:
|
class TestWaylandInserter:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch("whisper_local.inserter.asyncio.sleep", new_callable=AsyncMock)
|
@patch("whisper_local.inserter._wayland.asyncio.sleep", new_callable=AsyncMock)
|
||||||
@patch("whisper_local.inserter.asyncio.create_subprocess_exec")
|
@patch("whisper_local.inserter._wayland.asyncio.create_subprocess_exec")
|
||||||
async def test_insert_text_calls_tools_in_order(self, mock_exec, mock_sleep):
|
async def test_insert_text_calls_tools_in_order(self, mock_exec, mock_sleep):
|
||||||
mock_proc = AsyncMock()
|
mock_proc = AsyncMock()
|
||||||
mock_proc.communicate.return_value = (b"alter clipboard", b"")
|
mock_proc.communicate.return_value = (b"alter clipboard", b"")
|
||||||
@@ -17,31 +18,24 @@ class TestInserter:
|
|||||||
mock_proc.wait = AsyncMock()
|
mock_proc.wait = AsyncMock()
|
||||||
mock_exec.return_value = mock_proc
|
mock_exec.return_value = mock_proc
|
||||||
|
|
||||||
inserter = Inserter()
|
inserter = WaylandInserter()
|
||||||
await inserter.insert("Hallo Welt")
|
await inserter.insert("Hallo Welt")
|
||||||
|
|
||||||
calls = mock_exec.call_args_list
|
calls = mock_exec.call_args_list
|
||||||
assert len(calls) == 4
|
assert len(calls) == 4
|
||||||
# wl-paste mit PIPE (braucht stdout)
|
|
||||||
assert calls[0][0] == ("wl-paste", "--no-newline")
|
assert calls[0][0] == ("wl-paste", "--no-newline")
|
||||||
# wl-copy mit DEVNULL (forkt Hintergrundprozess)
|
|
||||||
assert calls[1][0] == ("wl-copy", "--", "Hallo Welt")
|
assert calls[1][0] == ("wl-copy", "--", "Hallo Welt")
|
||||||
# ydotool key
|
|
||||||
assert calls[2][0][0] == "ydotool"
|
assert calls[2][0][0] == "ydotool"
|
||||||
# wl-copy restore
|
|
||||||
assert calls[3][0] == ("wl-copy", "--", "alter clipboard")
|
assert calls[3][0] == ("wl-copy", "--", "alter clipboard")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch("whisper_local.inserter.asyncio.create_subprocess_exec")
|
async def test_insert_empty_text_does_nothing(self):
|
||||||
async def test_insert_empty_text_does_nothing(self, mock_exec):
|
inserter = WaylandInserter()
|
||||||
inserter = Inserter()
|
|
||||||
await inserter.insert("")
|
await inserter.insert("")
|
||||||
|
|
||||||
mock_exec.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch("whisper_local.inserter.asyncio.sleep", new_callable=AsyncMock)
|
@patch("whisper_local.inserter._wayland.asyncio.sleep", new_callable=AsyncMock)
|
||||||
@patch("whisper_local.inserter.asyncio.create_subprocess_exec")
|
@patch("whisper_local.inserter._wayland.asyncio.create_subprocess_exec")
|
||||||
async def test_clipboard_restore_on_paste_failure(self, mock_exec, mock_sleep):
|
async def test_clipboard_restore_on_paste_failure(self, mock_exec, mock_sleep):
|
||||||
call_count = 0
|
call_count = 0
|
||||||
|
|
||||||
@@ -61,9 +55,8 @@ class TestInserter:
|
|||||||
|
|
||||||
mock_exec.side_effect = mock_create_proc
|
mock_exec.side_effect = mock_create_proc
|
||||||
|
|
||||||
inserter = Inserter()
|
inserter = WaylandInserter()
|
||||||
with pytest.raises(OSError):
|
with pytest.raises(OSError):
|
||||||
await inserter.insert("Test")
|
await inserter.insert("Test")
|
||||||
|
|
||||||
# Clipboard muss trotzdem wiederhergestellt werden (finally-Block)
|
|
||||||
assert call_count == 4
|
assert call_count == 4
|
||||||
|
|||||||
+8
-4
@@ -7,18 +7,20 @@ from whisper_local.__main__ import App
|
|||||||
|
|
||||||
|
|
||||||
class TestApp:
|
class TestApp:
|
||||||
|
@patch("whisper_local.__main__.Inserter")
|
||||||
@patch("whisper_local.__main__.Transcriber")
|
@patch("whisper_local.__main__.Transcriber")
|
||||||
@patch("whisper_local.__main__.HotkeyListener")
|
@patch("whisper_local.__main__.HotkeyListener")
|
||||||
def test_app_init(self, mock_hotkey_class, mock_transcriber_class):
|
def test_app_init(self, mock_hotkey_class, mock_transcriber_class, mock_inserter_class):
|
||||||
app = App()
|
app = App()
|
||||||
assert app.recorder is not None
|
assert app.recorder is not None
|
||||||
assert app.inserter is not None
|
assert app.inserter is not None
|
||||||
mock_transcriber_class.assert_called_once()
|
mock_transcriber_class.assert_called_once()
|
||||||
mock_hotkey_class.assert_called_once()
|
mock_hotkey_class.assert_called_once()
|
||||||
|
|
||||||
|
@patch("whisper_local.__main__.Inserter")
|
||||||
@patch("whisper_local.__main__.Transcriber")
|
@patch("whisper_local.__main__.Transcriber")
|
||||||
@patch("whisper_local.__main__.HotkeyListener")
|
@patch("whisper_local.__main__.HotkeyListener")
|
||||||
def test_on_press_starts_recording(self, mock_hotkey_class, mock_transcriber_class):
|
def test_on_press_starts_recording(self, mock_hotkey_class, mock_transcriber_class, mock_inserter_class):
|
||||||
app = App()
|
app = App()
|
||||||
app.recorder = MagicMock()
|
app.recorder = MagicMock()
|
||||||
|
|
||||||
@@ -27,9 +29,10 @@ class TestApp:
|
|||||||
|
|
||||||
app.recorder.start.assert_called_once()
|
app.recorder.start.assert_called_once()
|
||||||
|
|
||||||
|
@patch("whisper_local.__main__.Inserter")
|
||||||
@patch("whisper_local.__main__.Transcriber")
|
@patch("whisper_local.__main__.Transcriber")
|
||||||
@patch("whisper_local.__main__.HotkeyListener")
|
@patch("whisper_local.__main__.HotkeyListener")
|
||||||
def test_on_release_stops_and_transcribes(self, mock_hotkey_class, mock_transcriber_class):
|
def test_on_release_stops_and_transcribes(self, mock_hotkey_class, mock_transcriber_class, mock_inserter_class):
|
||||||
mock_transcriber = MagicMock()
|
mock_transcriber = MagicMock()
|
||||||
mock_transcriber.transcribe.return_value = "Hallo"
|
mock_transcriber.transcribe.return_value = "Hallo"
|
||||||
mock_transcriber_class.return_value = mock_transcriber
|
mock_transcriber_class.return_value = mock_transcriber
|
||||||
@@ -48,9 +51,10 @@ class TestApp:
|
|||||||
mock_transcriber.transcribe.assert_called_once_with(audio)
|
mock_transcriber.transcribe.assert_called_once_with(audio)
|
||||||
app.inserter.insert.assert_awaited_once_with("Hallo")
|
app.inserter.insert.assert_awaited_once_with("Hallo")
|
||||||
|
|
||||||
|
@patch("whisper_local.__main__.Inserter")
|
||||||
@patch("whisper_local.__main__.Transcriber")
|
@patch("whisper_local.__main__.Transcriber")
|
||||||
@patch("whisper_local.__main__.HotkeyListener")
|
@patch("whisper_local.__main__.HotkeyListener")
|
||||||
def test_on_release_no_audio_skips(self, mock_hotkey_class, mock_transcriber_class):
|
def test_on_release_no_audio_skips(self, mock_hotkey_class, mock_transcriber_class, mock_inserter_class):
|
||||||
mock_transcriber = MagicMock()
|
mock_transcriber = MagicMock()
|
||||||
mock_transcriber_class.return_value = mock_transcriber
|
mock_transcriber_class.return_value = mock_transcriber
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Text-Einfügung — plattformspezifische Backends hinter gemeinsamem Interface."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class Inserter(Protocol):
|
||||||
|
async def insert(self, text: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def create_inserter() -> Inserter:
|
||||||
|
"""Erstellt den plattformspezifischen Inserter."""
|
||||||
|
if sys.platform == "linux":
|
||||||
|
from whisper_local.inserter._wayland import WaylandInserter
|
||||||
|
return WaylandInserter()
|
||||||
|
else:
|
||||||
|
from whisper_local.inserter._win32 import Win32Inserter
|
||||||
|
return Win32Inserter()
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
"""Text-Einfügung via wl-copy + ydotool Ctrl+V."""
|
"""Text-Einfügung via wl-copy + ydotool Ctrl+V (Linux/Wayland)."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PASTE_DELAY = 0.2 # Sekunden Wartezeit vor Clipboard-Restore
|
PASTE_DELAY = 0.2
|
||||||
|
|
||||||
|
|
||||||
class Inserter:
|
class WaylandInserter:
|
||||||
async def _run_capture(self, *cmd: str) -> bytes:
|
async def _run_capture(self, *cmd: str) -> bytes:
|
||||||
"""Führt einen Befehl aus und gibt stdout zurück."""
|
"""Führt einen Befehl aus und gibt stdout zurück."""
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
@@ -35,20 +35,12 @@ class Inserter:
|
|||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 1. Aktuelle Zwischenablage sichern
|
|
||||||
old_clipboard = await self._run_capture("wl-paste", "--no-newline")
|
old_clipboard = await self._run_capture("wl-paste", "--no-newline")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 2. Text in Zwischenablage kopieren
|
|
||||||
await self._run_fire("wl-copy", "--", text)
|
await self._run_fire("wl-copy", "--", text)
|
||||||
|
|
||||||
# 3. Kurz warten damit Clipboard bereit ist
|
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
# 4. Ctrl+V simulieren via ydotool (Ctrl=29, V=47)
|
|
||||||
await self._run_fire("ydotool", "key", "29:1", "47:1", "47:0", "29:0")
|
await self._run_fire("ydotool", "key", "29:1", "47:1", "47:0", "29:0")
|
||||||
|
|
||||||
# 5. Warten, dann Clipboard wiederherstellen
|
|
||||||
await asyncio.sleep(PASTE_DELAY)
|
await asyncio.sleep(PASTE_DELAY)
|
||||||
finally:
|
finally:
|
||||||
await self._run_fire("wl-copy", "--", old_clipboard.decode())
|
await self._run_fire("wl-copy", "--", old_clipboard.decode())
|
||||||
Reference in New Issue
Block a user