diff --git a/tests/test_inserter.py b/tests/test_inserter.py index e4c30d4..75d08e9 100644 --- a/tests/test_inserter.py +++ b/tests/test_inserter.py @@ -1,15 +1,16 @@ import asyncio +import sys from unittest.mock import AsyncMock, patch, call import pytest -from whisper_local.inserter import Inserter +from whisper_local.inserter._wayland import WaylandInserter -class TestInserter: +class TestWaylandInserter: @pytest.mark.asyncio - @patch("whisper_local.inserter.asyncio.sleep", new_callable=AsyncMock) - @patch("whisper_local.inserter.asyncio.create_subprocess_exec") + @patch("whisper_local.inserter._wayland.asyncio.sleep", new_callable=AsyncMock) + @patch("whisper_local.inserter._wayland.asyncio.create_subprocess_exec") async def test_insert_text_calls_tools_in_order(self, mock_exec, mock_sleep): mock_proc = AsyncMock() mock_proc.communicate.return_value = (b"alter clipboard", b"") @@ -17,31 +18,24 @@ class TestInserter: mock_proc.wait = AsyncMock() mock_exec.return_value = mock_proc - inserter = Inserter() + inserter = WaylandInserter() await inserter.insert("Hallo Welt") calls = mock_exec.call_args_list assert len(calls) == 4 - # wl-paste mit PIPE (braucht stdout) assert calls[0][0] == ("wl-paste", "--no-newline") - # wl-copy mit DEVNULL (forkt Hintergrundprozess) assert calls[1][0] == ("wl-copy", "--", "Hallo Welt") - # ydotool key assert calls[2][0][0] == "ydotool" - # wl-copy restore assert calls[3][0] == ("wl-copy", "--", "alter clipboard") @pytest.mark.asyncio - @patch("whisper_local.inserter.asyncio.create_subprocess_exec") - async def test_insert_empty_text_does_nothing(self, mock_exec): - inserter = Inserter() + async def test_insert_empty_text_does_nothing(self): + inserter = WaylandInserter() 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") + @patch("whisper_local.inserter._wayland.asyncio.sleep", new_callable=AsyncMock) + @patch("whisper_local.inserter._wayland.asyncio.create_subprocess_exec") async def test_clipboard_restore_on_paste_failure(self, mock_exec, mock_sleep): call_count = 0 @@ -61,9 +55,8 @@ class TestInserter: mock_exec.side_effect = mock_create_proc - inserter = Inserter() + inserter = WaylandInserter() with pytest.raises(OSError): await inserter.insert("Test") - # Clipboard muss trotzdem wiederhergestellt werden (finally-Block) assert call_count == 4 diff --git a/tests/test_main.py b/tests/test_main.py index 95a0986..e3cc7d9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -7,18 +7,20 @@ from whisper_local.__main__ import App class TestApp: + @patch("whisper_local.__main__.Inserter") @patch("whisper_local.__main__.Transcriber") @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() assert app.recorder is not None assert app.inserter is not None mock_transcriber_class.assert_called_once() mock_hotkey_class.assert_called_once() + @patch("whisper_local.__main__.Inserter") @patch("whisper_local.__main__.Transcriber") @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.recorder = MagicMock() @@ -27,9 +29,10 @@ class TestApp: app.recorder.start.assert_called_once() + @patch("whisper_local.__main__.Inserter") @patch("whisper_local.__main__.Transcriber") @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.transcribe.return_value = "Hallo" mock_transcriber_class.return_value = mock_transcriber @@ -48,9 +51,10 @@ class TestApp: mock_transcriber.transcribe.assert_called_once_with(audio) app.inserter.insert.assert_awaited_once_with("Hallo") + @patch("whisper_local.__main__.Inserter") @patch("whisper_local.__main__.Transcriber") @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_class.return_value = mock_transcriber diff --git a/whisper_local/inserter/__init__.py b/whisper_local/inserter/__init__.py new file mode 100644 index 0000000..f9bf7ff --- /dev/null +++ b/whisper_local/inserter/__init__.py @@ -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() diff --git a/whisper_local/inserter.py b/whisper_local/inserter/_wayland.py similarity index 78% rename from whisper_local/inserter.py rename to whisper_local/inserter/_wayland.py index 782c9ab..090a421 100644 --- a/whisper_local/inserter.py +++ b/whisper_local/inserter/_wayland.py @@ -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 logging 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: """Führt einen Befehl aus und gibt stdout zurück.""" proc = await asyncio.create_subprocess_exec( @@ -35,20 +35,12 @@ class Inserter: if not text: return - # 1. Aktuelle Zwischenablage sichern old_clipboard = await self._run_capture("wl-paste", "--no-newline") try: - # 2. Text in Zwischenablage kopieren await self._run_fire("wl-copy", "--", text) - - # 3. Kurz warten damit Clipboard bereit ist 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") - - # 5. Warten, dann Clipboard wiederherstellen await asyncio.sleep(PASTE_DELAY) finally: await self._run_fire("wl-copy", "--", old_clipboard.decode())