"""Integration tests for mode control system."""
import pytest
import time
from unittest.mock import Mock, patch
from voice_client import VoiceModeController
from src.localvoicemode.state import VoiceMode
class TestModeControlIntegration:
"""Integration tests for VoiceModeController mode switching."""
@pytest.fixture
def controller(self):
"""Create controller with mocked audio components."""
with patch("voice_client.TTSEngine"), \
patch("voice_client.ASREngine"), \
patch("voice_client.AudioRecorder"):
ctrl = VoiceModeController()
return ctrl
# Mode switching tests
def test_initial_mode_is_full_voice(self, controller):
"""Controller starts in FULL_VOICE mode."""
assert controller.mode_machine.mode == VoiceMode.FULL_VOICE
assert controller.mode_machine.stt_enabled is True
assert controller.mode_machine.tts_enabled is True
def test_set_voice_mode_tts_only(self, controller):
"""Can switch to TTS_ONLY mode."""
result = controller.set_voice_mode("tts_only")
assert result["mode"] == "tts_only"
assert result["stt_enabled"] is False
assert result["tts_enabled"] is True
assert "error" not in result
def test_set_voice_mode_stt_only(self, controller):
"""Can switch to STT_ONLY mode."""
result = controller.set_voice_mode("stt_only")
assert result["mode"] == "stt_only"
assert result["stt_enabled"] is True
assert result["tts_enabled"] is False
def test_set_voice_mode_silent(self, controller):
"""Can switch to SILENT mode."""
result = controller.set_voice_mode("silent")
assert result["mode"] == "silent"
assert result["stt_enabled"] is False
assert result["tts_enabled"] is False
def test_set_voice_mode_back_to_full(self, controller):
"""Can return to FULL_VOICE from any mode."""
controller.set_voice_mode("silent")
result = controller.set_voice_mode("full_voice")
assert result["mode"] == "full_voice"
assert result["stt_enabled"] is True
assert result["tts_enabled"] is True
# Alias tests
def test_mode_alias_tts(self, controller):
"""'tts' alias works for tts_only."""
result = controller.set_voice_mode("tts")
assert result["mode"] == "tts_only"
def test_mode_alias_stt(self, controller):
"""'stt' alias works for stt_only."""
result = controller.set_voice_mode("stt")
assert result["mode"] == "stt_only"
def test_mode_alias_mute(self, controller):
"""'mute' alias works for silent."""
result = controller.set_voice_mode("mute")
assert result["mode"] == "silent"
def test_mode_alias_full(self, controller):
"""'full' alias works for full_voice."""
controller.set_voice_mode("silent")
result = controller.set_voice_mode("full")
assert result["mode"] == "full_voice"
def test_mode_case_insensitive(self, controller):
"""Mode names are case-insensitive."""
result = controller.set_voice_mode("TTS_ONLY")
assert result["mode"] == "tts_only"
def test_mode_case_insensitive_mixed(self, controller):
"""Mode names with mixed case work."""
result = controller.set_voice_mode("Stt_Only")
assert result["mode"] == "stt_only"
# Error handling
def test_invalid_mode_returns_error(self, controller):
"""Invalid mode returns error dict."""
result = controller.set_voice_mode("invalid_mode")
assert "error" in result
assert "invalid_mode" in result["error"]
# Mode should not change
assert controller.mode_machine.mode == VoiceMode.FULL_VOICE
def test_invalid_mode_preserves_state(self, controller):
"""Invalid mode attempt does not change current mode."""
controller.set_voice_mode("tts_only")
result = controller.set_voice_mode("bad_mode")
assert "error" in result
# Should still be tts_only
assert controller.mode_machine.mode == VoiceMode.TTS_ONLY
# Performance test
def test_mode_transition_under_500ms(self, controller):
"""Mode transitions complete in <500ms (MODE-05)."""
result = controller.set_voice_mode("tts_only")
assert "transition_ms" in result
assert result["transition_ms"] < 500
def test_multiple_transitions_all_fast(self, controller):
"""Multiple transitions all complete under 500ms."""
modes = ["tts_only", "stt_only", "silent", "full_voice"]
for mode in modes:
result = controller.set_voice_mode(mode)
assert result["transition_ms"] < 500, f"Transition to {mode} too slow"
# Conversation preservation (MODE-04)
def test_conversation_preserved_across_modes(self, controller):
"""Conversation state persists when switching modes."""
# Add some conversation history
controller.conversation.add_turn("user", "Hello")
controller.conversation.add_turn("assistant", "Hi there!")
initial_count = controller.conversation.turn_count
# Switch modes multiple times
controller.set_voice_mode("tts_only")
controller.set_voice_mode("silent")
controller.set_voice_mode("full_voice")
# Conversation should be intact
assert controller.conversation.turn_count == initial_count
assert controller.conversation.last_user_text == "Hello"
assert controller.conversation.last_response == "Hi there!"
def test_conversation_history_intact_after_mode_cycle(self, controller):
"""Full conversation history intact after mode cycling."""
controller.conversation.add_turn("user", "First")
controller.conversation.add_turn("assistant", "Response 1")
controller.conversation.add_turn("user", "Second")
controller.conversation.add_turn("assistant", "Response 2")
# Cycle through all modes
for mode in ["tts", "stt", "mute", "full"]:
controller.set_voice_mode(mode)
# Check full history
history = controller.conversation.history
assert len(history) == 4
assert history[0] == ("user", "First")
assert history[3] == ("assistant", "Response 2")
# Should-process checks
def test_should_process_audio_in_full_voice(self, controller):
"""_should_process_audio() returns True in FULL_VOICE."""
assert controller._should_process_audio() is True
def test_should_not_process_audio_in_tts_only(self, controller):
"""_should_process_audio() returns False in TTS_ONLY."""
controller.set_voice_mode("tts_only")
assert controller._should_process_audio() is False
def test_should_not_process_audio_in_silent(self, controller):
"""_should_process_audio() returns False in SILENT."""
controller.set_voice_mode("silent")
assert controller._should_process_audio() is False
def test_should_process_audio_in_stt_only(self, controller):
"""_should_process_audio() returns True in STT_ONLY."""
controller.set_voice_mode("stt_only")
assert controller._should_process_audio() is True
def test_should_speak_in_full_voice(self, controller):
"""_should_speak() returns True in FULL_VOICE."""
assert controller._should_speak() is True
def test_should_speak_in_tts_only(self, controller):
"""_should_speak() returns True in TTS_ONLY."""
controller.set_voice_mode("tts_only")
assert controller._should_speak() is True
def test_should_not_speak_in_stt_only(self, controller):
"""_should_speak() returns False in STT_ONLY."""
controller.set_voice_mode("stt_only")
assert controller._should_speak() is False
def test_should_not_speak_in_silent(self, controller):
"""_should_speak() returns False in SILENT."""
controller.set_voice_mode("silent")
assert controller._should_speak() is False
# Status tests
def test_get_status_includes_mode(self, controller):
"""get_status() includes current mode."""
status = controller.get_status()
assert "mode" in status
assert status["mode"] == "full_voice"
assert "stt_enabled" in status
assert "tts_enabled" in status
assert "turn_state" in status
def test_get_status_reflects_mode_change(self, controller):
"""get_status() reflects mode changes."""
controller.set_voice_mode("tts_only")
status = controller.get_status()
assert status["mode"] == "tts_only"
assert status["stt_enabled"] is False
assert status["tts_enabled"] is True
def test_get_status_includes_all_fields(self, controller):
"""get_status() includes all expected fields."""
status = controller.get_status()
expected_keys = [
"running", "mode", "turn_state",
"stt_enabled", "tts_enabled",
"conversation_turns", "skill"
]
for key in expected_keys:
assert key in status, f"Missing key: {key}"
def test_get_status_conversation_turns(self, controller):
"""get_status() shows correct conversation turn count."""
assert controller.get_status()["conversation_turns"] == 0
controller.conversation.add_turn("user", "Hello")
assert controller.get_status()["conversation_turns"] == 1
controller.conversation.add_turn("assistant", "Hi")
assert controller.get_status()["conversation_turns"] == 2
class TestTurnStateIntegration:
"""Integration tests for turn-taking state machine."""
@pytest.fixture
def controller(self):
"""Create controller with mocked audio components."""
with patch("voice_client.TTSEngine"), \
patch("voice_client.ASREngine"), \
patch("voice_client.AudioRecorder"):
ctrl = VoiceModeController()
return ctrl
def test_initial_turn_state_is_idle(self, controller):
"""Turn state starts as IDLE."""
assert controller.turn_machine.is_idle() is True
def test_turn_state_in_status(self, controller):
"""get_status() includes turn_state."""
status = controller.get_status()
assert status["turn_state"] == "idle"
def test_mode_switch_cancels_active_turn(self, controller):
"""Mode switch cancels any in-progress turn."""
# Simulate active listening
controller.turn_machine.start_listening()
assert controller.turn_machine.is_listening() is True
# Switch mode
controller.set_voice_mode("tts_only")
# Turn should be cancelled
assert controller.turn_machine.is_idle() is True
def test_mode_switch_cancels_processing_turn(self, controller):
"""Mode switch cancels processing turn."""
controller.turn_machine.start_listening()
controller.turn_machine.voice_detected()
assert controller.turn_machine.is_processing() is True
controller.set_voice_mode("silent")
assert controller.turn_machine.is_idle() is True
def test_mode_switch_cancels_speaking_turn(self, controller):
"""Mode switch cancels speaking turn."""
controller.turn_machine.safe_send("start_speaking")
assert controller.turn_machine.is_speaking() is True
controller.set_voice_mode("stt_only")
assert controller.turn_machine.is_idle() is True
def test_should_not_process_audio_when_speaking(self, controller):
"""_should_process_audio() returns False when TTS is active."""
controller.turn_machine.safe_send("start_speaking")
# Even in FULL_VOICE mode, shouldn't process audio while speaking
assert controller._should_process_audio() is False
def test_should_not_process_audio_when_processing(self, controller):
"""_should_process_audio() returns False when processing."""
controller.turn_machine.start_listening()
controller.turn_machine.voice_detected()
# Shouldn't record new audio while processing previous
assert controller._should_process_audio() is False
def test_turn_state_transitions_correctly(self, controller):
"""Turn state follows expected flow."""
# Start idle
assert controller.turn_machine.state_name == "idle"
# Start listening
controller.turn_machine.start_listening()
assert controller.turn_machine.state_name == "listening"
# Voice detected -> processing
controller.turn_machine.voice_detected()
assert controller.turn_machine.state_name == "processing"
# Start speaking
controller.turn_machine.start_speaking()
assert controller.turn_machine.state_name == "speaking"
# Finish speaking -> back to idle
controller.turn_machine.finish_speaking()
assert controller.turn_machine.state_name == "idle"
def test_mode_switch_from_idle_no_cancel(self, controller):
"""Mode switch from idle doesn't need to cancel."""
assert controller.turn_machine.is_idle() is True
# Should complete without issues
result = controller.set_voice_mode("tts_only")
assert "error" not in result
assert result["mode"] == "tts_only"