# -*- coding: utf-8 -*- """ Tests für Keyboard Input Service. Testet: - KeyboardInputService Initialisierung und Lifecycle - SimpleKeyboardInput Fallback - create_keyboard_input Factory - Conversation State Management - Follow-Up Handling """ import asyncio import pytest from unittest.mock import MagicMock, AsyncMock, patch from trixy_core.input.keyboard_input import ( KeyboardInputService, SimpleKeyboardInput, create_keyboard_input, _check_prompt_toolkit, ) from trixy_core.network.cmd import FollowUpRequest, TextInput # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture def mock_app(): """Mock-Application mit Events und Properties.""" app = MagicMock() app.room = "test_room" app.alias = "test_alias" app.shutdown = MagicMock() # Events Mock app.events = MagicMock() app.events.trigger = AsyncMock() return app @pytest.fixture def mock_connection(): """Mock-ClientConnection.""" conn = MagicMock() conn.is_connected = True conn.send_command = AsyncMock(return_value=True) return conn @pytest.fixture def keyboard_service(mock_app): """Erstellt einen KeyboardInputService ohne Connection.""" return KeyboardInputService(mock_app, connection=None, prompt=">>> ") @pytest.fixture def keyboard_service_with_connection(mock_app, mock_connection): """Erstellt einen KeyboardInputService mit Connection.""" return KeyboardInputService(mock_app, connection=mock_connection, prompt=">>> ") @pytest.fixture def simple_input(mock_app): """Erstellt einen SimpleKeyboardInput.""" return SimpleKeyboardInput(mock_app, connection=None, prompt=">>> ") @pytest.fixture def simple_input_with_connection(mock_app, mock_connection): """Erstellt einen SimpleKeyboardInput mit Connection.""" return SimpleKeyboardInput(mock_app, connection=mock_connection, prompt=">>> ") # ============================================================================= # Tests für _check_prompt_toolkit # ============================================================================= class TestCheckPromptToolkit: """Tests für die prompt_toolkit Verfügbarkeitsprüfung.""" def test_returns_bool(self): """Testet, dass ein Boolean zurückgegeben wird.""" result = _check_prompt_toolkit() assert isinstance(result, bool) def test_result_is_cached(self): """Testet, dass das Ergebnis gecacht wird.""" result1 = _check_prompt_toolkit() result2 = _check_prompt_toolkit() assert result1 == result2 # ============================================================================= # Tests für KeyboardInputService - Initialisierung # ============================================================================= class TestKeyboardInputServiceInit: """Tests für KeyboardInputService Initialisierung.""" def test_initial_state(self, keyboard_service): """Testet den Initialzustand.""" assert keyboard_service.is_running is False assert keyboard_service.has_active_session is False assert keyboard_service._input_active is False def test_initial_prompt(self, keyboard_service): """Testet den initialen Prompt.""" assert keyboard_service._prompt == ">>> " def test_custom_prompt(self, mock_app): """Testet benutzerdefinierten Prompt.""" service = KeyboardInputService(mock_app, prompt="Test> ") assert service._prompt == "Test> " def test_no_connection_initially(self, keyboard_service): """Testet, dass keine Connection initial gesetzt ist.""" assert keyboard_service._connection is None def test_with_connection(self, keyboard_service_with_connection, mock_connection): """Testet Service mit Connection.""" assert keyboard_service_with_connection._connection is mock_connection # ============================================================================= # Tests für KeyboardInputService - Properties # ============================================================================= class TestKeyboardInputServiceProperties: """Tests für KeyboardInputService Properties.""" def test_is_running_false_initially(self, keyboard_service): """Testet is_running initial.""" assert keyboard_service.is_running is False def test_has_active_session_false_initially(self, keyboard_service): """Testet has_active_session initial.""" assert keyboard_service.has_active_session is False def test_has_active_session_true_with_session(self, keyboard_service): """Testet has_active_session mit Session.""" keyboard_service._current_session_id = "test-session-123" assert keyboard_service.has_active_session is True def test_has_active_session_false_with_empty_session(self, keyboard_service): """Testet has_active_session mit leerer Session.""" keyboard_service._current_session_id = "" assert keyboard_service.has_active_session is False # ============================================================================= # Tests für KeyboardInputService - set_connection # ============================================================================= class TestKeyboardInputServiceSetConnection: """Tests für set_connection Methode.""" def test_set_connection(self, keyboard_service, mock_connection): """Testet Setzen der Connection.""" assert keyboard_service._connection is None keyboard_service.set_connection(mock_connection) assert keyboard_service._connection is mock_connection # ============================================================================= # Tests für KeyboardInputService - Conversation State # ============================================================================= class TestKeyboardInputServiceConversationState: """Tests für Conversation State Management.""" @pytest.mark.asyncio async def test_request_input_new_conversation(self, keyboard_service): """Testet request_input für neue Conversation.""" await keyboard_service.request_input() assert keyboard_service._awaiting_follow_up is False assert keyboard_service._follow_up_question == "" assert keyboard_service._follow_up_number == 0 assert keyboard_service._current_session_id == "" @pytest.mark.asyncio async def test_request_input_follow_up(self, keyboard_service): """Testet request_input für Follow-Up.""" await keyboard_service.request_input( follow_up_question="Was meinst du damit?", session_id="session-123" ) assert keyboard_service._awaiting_follow_up is True assert keyboard_service._follow_up_question == "Was meinst du damit?" assert keyboard_service._follow_up_number == 1 assert keyboard_service._current_session_id == "session-123" @pytest.mark.asyncio async def test_request_input_multiple_follow_ups(self, keyboard_service): """Testet mehrere Follow-Ups hintereinander.""" await keyboard_service.request_input( follow_up_question="Frage 1?", session_id="session-123" ) assert keyboard_service._follow_up_number == 1 await keyboard_service.request_input( follow_up_question="Frage 2?", session_id="session-123" ) assert keyboard_service._follow_up_number == 2 @pytest.mark.asyncio async def test_handle_follow_up_request(self, keyboard_service): """Testet handle_follow_up_request.""" request = FollowUpRequest( session_id="session-456", question="Kannst du das genauer erklären?" ) # Muss als Task erstellt werden (async) keyboard_service.handle_follow_up_request(request) # Kurz warten damit Task ausgeführt wird await asyncio.sleep(0.01) assert keyboard_service._awaiting_follow_up is True assert keyboard_service._follow_up_question == "Kannst du das genauer erklären?" assert keyboard_service._current_session_id == "session-456" def test_handle_conversation_end_matching_session(self, keyboard_service): """Testet handle_conversation_end mit passender Session.""" keyboard_service._current_session_id = "session-789" keyboard_service._awaiting_follow_up = True keyboard_service._follow_up_question = "Test?" keyboard_service._follow_up_number = 3 keyboard_service.handle_conversation_end("session-789") assert keyboard_service._current_session_id == "" assert keyboard_service._awaiting_follow_up is False assert keyboard_service._follow_up_question == "" assert keyboard_service._follow_up_number == 0 def test_handle_conversation_end_different_session(self, keyboard_service): """Testet handle_conversation_end mit anderer Session.""" keyboard_service._current_session_id = "session-789" keyboard_service._awaiting_follow_up = True keyboard_service._follow_up_number = 3 keyboard_service.handle_conversation_end("session-other") # State sollte unverändert sein assert keyboard_service._current_session_id == "session-789" assert keyboard_service._awaiting_follow_up is True assert keyboard_service._follow_up_number == 3 # ============================================================================= # Tests für KeyboardInputService - _process_input # ============================================================================= class TestKeyboardInputServiceProcessInput: """Tests für _process_input Methode.""" @pytest.mark.asyncio async def test_process_input_standalone_mode(self, keyboard_service, mock_app): """Testet Input-Verarbeitung im Standalone-Modus (keine Connection).""" await keyboard_service._process_input("Hallo Trixy") # Event sollte ausgelöst worden sein mock_app.events.trigger.assert_called_once() # Prüfe Event-Parameter call_args = mock_app.events.trigger.call_args assert call_args[0][0] == "speech_recognized" event_data = call_args[0][1] assert event_data.text == "Hallo Trixy" assert event_data.source == "keyboard" assert event_data.confidence == 1.0 @pytest.mark.asyncio async def test_process_input_client_mode(self, keyboard_service_with_connection, mock_connection): """Testet Input-Verarbeitung im Client-Modus (mit Connection).""" await keyboard_service_with_connection._process_input("Test Input") # Command sollte gesendet worden sein mock_connection.send_command.assert_called_once() # Prüfe Command-Parameter call_args = mock_connection.send_command.call_args cmd = call_args[0][0] assert isinstance(cmd, TextInput) assert cmd.text == "Test Input" assert cmd.satellite_id == "test_alias" assert cmd.room_id == "test_room" assert cmd.language == "de" @pytest.mark.asyncio async def test_process_input_generates_session_id(self, keyboard_service): """Testet, dass Session-ID generiert wird.""" assert keyboard_service._current_session_id == "" await keyboard_service._process_input("Test") assert keyboard_service._current_session_id != "" assert keyboard_service._current_session_id.startswith("kb-") @pytest.mark.asyncio async def test_process_input_preserves_session_id(self, keyboard_service): """Testet, dass existierende Session-ID beibehalten wird.""" keyboard_service._current_session_id = "existing-session" await keyboard_service._process_input("Test") assert keyboard_service._current_session_id == "existing-session" @pytest.mark.asyncio async def test_process_input_resets_follow_up(self, keyboard_service): """Testet, dass Follow-Up-State zurückgesetzt wird.""" keyboard_service._awaiting_follow_up = True keyboard_service._follow_up_question = "Test?" await keyboard_service._process_input("Antwort") assert keyboard_service._awaiting_follow_up is False assert keyboard_service._follow_up_question == "" @pytest.mark.asyncio async def test_process_input_follow_up_flag(self, keyboard_service_with_connection, mock_connection): """Testet, dass Follow-Up-Flag im Command gesetzt wird.""" keyboard_service_with_connection._awaiting_follow_up = True keyboard_service_with_connection._follow_up_number = 2 keyboard_service_with_connection._current_session_id = "session-123" await keyboard_service_with_connection._process_input("Follow-Up Antwort") call_args = mock_connection.send_command.call_args cmd = call_args[0][0] assert cmd.is_follow_up is True assert cmd.follow_up_number == 2 # ============================================================================= # Tests für SimpleKeyboardInput - Initialisierung # ============================================================================= class TestSimpleKeyboardInputInit: """Tests für SimpleKeyboardInput Initialisierung.""" def test_initial_state(self, simple_input): """Testet den Initialzustand.""" assert simple_input._running is False assert simple_input._task is None def test_initial_prompt(self, simple_input): """Testet den initialen Prompt.""" assert simple_input._prompt == ">>> " def test_custom_prompt(self, mock_app): """Testet benutzerdefinierten Prompt.""" service = SimpleKeyboardInput(mock_app, prompt="Simple> ") assert service._prompt == "Simple> " def test_conversation_state_initial(self, simple_input): """Testet initialen Conversation State.""" assert simple_input._current_session_id == "" assert simple_input._awaiting_follow_up is False assert simple_input._follow_up_number == 0 assert simple_input._follow_up_question == "" # ============================================================================= # Tests für SimpleKeyboardInput - set_connection # ============================================================================= class TestSimpleKeyboardInputSetConnection: """Tests für SimpleKeyboardInput set_connection.""" def test_set_connection(self, simple_input, mock_connection): """Testet Setzen der Connection.""" assert simple_input._connection is None simple_input.set_connection(mock_connection) assert simple_input._connection is mock_connection # ============================================================================= # Tests für SimpleKeyboardInput - Lifecycle # ============================================================================= class TestSimpleKeyboardInputLifecycle: """Tests für SimpleKeyboardInput Lifecycle.""" @pytest.mark.asyncio async def test_start_sets_running(self, simple_input): """Testet, dass start() _running setzt.""" # Patch input() um blocking zu vermeiden with patch('builtins.input', return_value=""): result = await simple_input.start() assert result is True assert simple_input._running is True assert simple_input._task is not None # Cleanup await simple_input.stop() @pytest.mark.asyncio async def test_start_already_running(self, simple_input): """Testet start() wenn bereits läuft.""" simple_input._running = True result = await simple_input.start() assert result is True @pytest.mark.asyncio async def test_stop_sets_not_running(self, simple_input): """Testet, dass stop() _running zurücksetzt.""" simple_input._running = True simple_input._task = asyncio.create_task(asyncio.sleep(10)) await simple_input.stop() assert simple_input._running is False # ============================================================================= # Tests für SimpleKeyboardInput - Conversation State # ============================================================================= class TestSimpleKeyboardInputConversationState: """Tests für SimpleKeyboardInput Conversation State.""" @pytest.mark.asyncio async def test_request_input_follow_up(self, simple_input): """Testet request_input für Follow-Up.""" await simple_input.request_input( follow_up_question="Follow-Up Frage?", session_id="session-abc" ) assert simple_input._awaiting_follow_up is True assert simple_input._follow_up_question == "Follow-Up Frage?" assert simple_input._follow_up_number == 1 assert simple_input._current_session_id == "session-abc" def test_handle_conversation_end(self, simple_input): """Testet handle_conversation_end.""" simple_input._current_session_id = "session-end" simple_input._awaiting_follow_up = True simple_input._follow_up_number = 5 simple_input.handle_conversation_end("session-end") assert simple_input._current_session_id == "" assert simple_input._awaiting_follow_up is False assert simple_input._follow_up_number == 0 # ============================================================================= # Tests für SimpleKeyboardInput - _process_input # ============================================================================= class TestSimpleKeyboardInputProcessInput: """Tests für SimpleKeyboardInput _process_input.""" @pytest.mark.asyncio async def test_process_input_standalone(self, simple_input, mock_app): """Testet Input-Verarbeitung ohne Connection.""" await simple_input._process_input("Simple Test") mock_app.events.trigger.assert_called_once() call_args = mock_app.events.trigger.call_args assert call_args[0][0] == "speech_recognized" event_data = call_args[0][1] assert event_data.text == "Simple Test" assert event_data.source == "keyboard" @pytest.mark.asyncio async def test_process_input_with_connection(self, simple_input_with_connection, mock_connection): """Testet Input-Verarbeitung mit Connection.""" await simple_input_with_connection._process_input("Connected Test") mock_connection.send_command.assert_called_once() call_args = mock_connection.send_command.call_args cmd = call_args[0][0] assert isinstance(cmd, TextInput) assert cmd.text == "Connected Test" @pytest.mark.asyncio async def test_process_input_resets_follow_up(self, simple_input): """Testet, dass Follow-Up zurückgesetzt wird.""" simple_input._awaiting_follow_up = True simple_input._follow_up_question = "Test?" await simple_input._process_input("Antwort") assert simple_input._awaiting_follow_up is False assert simple_input._follow_up_question == "" # ============================================================================= # Tests für create_keyboard_input Factory # ============================================================================= class TestCreateKeyboardInput: """Tests für create_keyboard_input Factory-Funktion.""" def test_returns_keyboard_input_service_or_simple(self, mock_app): """Testet, dass die Factory die richtige Klasse zurückgibt.""" result = create_keyboard_input(mock_app) # Je nach prompt_toolkit Verfügbarkeit assert isinstance(result, (KeyboardInputService, SimpleKeyboardInput)) def test_with_connection(self, mock_app, mock_connection): """Testet Factory mit Connection.""" result = create_keyboard_input(mock_app, connection=mock_connection) assert result._connection is mock_connection def test_with_custom_prompt(self, mock_app): """Testet Factory mit benutzerdefiniertem Prompt.""" result = create_keyboard_input(mock_app, prompt="Custom> ") assert result._prompt == "Custom> " def test_returns_simple_when_no_prompt_toolkit(self, mock_app): """Testet, dass SimpleKeyboardInput zurückgegeben wird ohne prompt_toolkit.""" with patch('trixy_core.input.keyboard_input._check_prompt_toolkit', return_value=False): # Importiere erneut um den Patch zu nutzen from trixy_core.input.keyboard_input import create_keyboard_input as factory result = factory(mock_app) assert isinstance(result, SimpleKeyboardInput) def test_returns_service_when_prompt_toolkit_available(self, mock_app): """Testet, dass KeyboardInputService zurückgegeben wird mit prompt_toolkit.""" with patch('trixy_core.input.keyboard_input._check_prompt_toolkit', return_value=True): from trixy_core.input.keyboard_input import create_keyboard_input as factory result = factory(mock_app) assert isinstance(result, KeyboardInputService) # ============================================================================= # Tests für TextInput Command # ============================================================================= class TestTextInputCommand: """Tests für TextInput Command Erstellung.""" @pytest.mark.asyncio async def test_text_input_command_fields(self, keyboard_service_with_connection, mock_connection): """Testet, dass alle TextInput Felder korrekt gesetzt werden.""" keyboard_service_with_connection._current_session_id = "test-session" keyboard_service_with_connection._awaiting_follow_up = True keyboard_service_with_connection._follow_up_number = 3 await keyboard_service_with_connection._process_input("Test Text") call_args = mock_connection.send_command.call_args cmd = call_args[0][0] assert cmd.satellite_id == "test_alias" assert cmd.text == "Test Text" assert cmd.session_id == "test-session" assert cmd.room_id == "test_room" assert cmd.language == "de" assert cmd.is_follow_up is True assert cmd.follow_up_number == 3 # ============================================================================= # Integration Tests # ============================================================================= class TestKeyboardInputIntegration: """Integrationstests für Keyboard Input.""" @pytest.mark.asyncio async def test_full_conversation_flow_standalone(self, keyboard_service, mock_app): """Testet vollständigen Conversation-Flow im Standalone-Modus.""" # Erste Eingabe await keyboard_service._process_input("Erste Nachricht") assert keyboard_service._current_session_id.startswith("kb-") session_id = keyboard_service._current_session_id # Follow-Up Request await keyboard_service.request_input( follow_up_question="Kannst du mehr sagen?", session_id=session_id ) assert keyboard_service._awaiting_follow_up is True # Follow-Up Antwort await keyboard_service._process_input("Mehr Details") assert keyboard_service._awaiting_follow_up is False # Conversation Ende keyboard_service.handle_conversation_end(session_id) assert keyboard_service._current_session_id == "" @pytest.mark.asyncio async def test_full_conversation_flow_client(self, keyboard_service_with_connection, mock_connection): """Testet vollständigen Conversation-Flow im Client-Modus.""" # Erste Eingabe await keyboard_service_with_connection._process_input("Client Nachricht") # Prüfe ersten Command first_call = mock_connection.send_command.call_args_list[0] first_cmd = first_call[0][0] assert first_cmd.is_follow_up is False session_id = keyboard_service_with_connection._current_session_id # Follow-Up Request await keyboard_service_with_connection.request_input( follow_up_question="Rückfrage?", session_id=session_id ) # Follow-Up Antwort await keyboard_service_with_connection._process_input("Follow-Up Antwort") # Prüfe zweiten Command second_call = mock_connection.send_command.call_args_list[1] second_cmd = second_call[0][0] assert second_cmd.is_follow_up is True @pytest.mark.asyncio async def test_multiple_sessions(self, keyboard_service, mock_app): """Testet mehrere unabhängige Sessions.""" # Erste Session await keyboard_service._process_input("Session 1") session1 = keyboard_service._current_session_id keyboard_service.handle_conversation_end(session1) # Zweite Session await keyboard_service._process_input("Session 2") session2 = keyboard_service._current_session_id # Sessions sollten unterschiedlich sein assert session1 != session2 assert session2.startswith("kb-") # ============================================================================= # Edge Cases # ============================================================================= class TestEdgeCases: """Tests für Grenzfälle.""" def test_handle_conversation_end_empty_session(self, keyboard_service): """Testet handle_conversation_end mit leerer Session.""" keyboard_service._current_session_id = "" keyboard_service.handle_conversation_end("") # Sollte keinen Fehler werfen def test_handle_conversation_end_none_like(self, keyboard_service): """Testet handle_conversation_end mit None-ähnlichem Wert.""" keyboard_service._current_session_id = "session-123" keyboard_service.handle_conversation_end("") # Session sollte unverändert sein assert keyboard_service._current_session_id == "session-123" @pytest.mark.asyncio async def test_process_input_missing_app_attributes(self, mock_app): """Testet _process_input wenn App-Attribute fehlen.""" # Entferne Attribute del mock_app.room del mock_app.alias service = KeyboardInputService(mock_app) # Sollte Defaults verwenden await service._process_input("Test") call_args = mock_app.events.trigger.call_args event_data = call_args[0][1] assert event_data.room_id == "local" assert event_data.satellite_id == "keyboard" @pytest.mark.asyncio async def test_connection_disconnected(self, keyboard_service_with_connection, mock_connection, mock_app): """Testet Verhalten bei getrennter Connection.""" mock_connection.is_connected = False await keyboard_service_with_connection._process_input("Offline Test") # Sollte auf Events zurückfallen mock_app.events.trigger.assert_called_once() mock_connection.send_command.assert_not_called() @pytest.mark.asyncio async def test_follow_up_request_with_empty_question(self, keyboard_service): """Testet handle_follow_up_request mit leerer Frage.""" request = FollowUpRequest(session_id="session-123", question="") keyboard_service.handle_follow_up_request(request) await asyncio.sleep(0.01) # Sollte keinen Fehler werfen