diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fd91464 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - run: python -m pip install --upgrade pip + - run: pip install -r requirements-dev.txt + - run: pytest diff --git a/Makefile b/Makefile index 3019bb6..5ac1ddf 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,10 @@ run-enhanced: $(ACT); OMP_NUM_THREADS=$(LLAMA_THREADS) $(PY) src/macbot/voice_assistant.py run-orchestrator: - $(ACT); $(PY) src/macbot/orchestrator.py + $(ACT); $(PY) src/macbot/orchestrator.py + +test: + $(ACT); pytest clean: - rm -rf $(VENV) $(WHISPER_DIR) $(LLAMA_DIR) __pycache__ */__pycache__ + rm -rf $(VENV) $(WHISPER_DIR) $(LLAMA_DIR) __pycache__ */__pycache__ diff --git a/tests/test_interruptible_conversation.py b/tests/test_interruptible_conversation.py index c462e3e..5787794 100644 --- a/tests/test_interruptible_conversation.py +++ b/tests/test_interruptible_conversation.py @@ -1,142 +1,68 @@ -#!/usr/bin/env python3 -""" -Test script for interruptible conversation system -""" - -import sys import os +import sys +import time +import threading +import numpy as np +import pytest +import types + +# Ensure the package can be imported without installation sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'src')) +# Provide stub modules for optional audio dependencies +sys.modules.setdefault('sounddevice', types.SimpleNamespace(OutputStream=object)) +sys.modules.setdefault('soundfile', types.SimpleNamespace()) + from macbot.audio_interrupt import AudioInterruptHandler -from macbot.conversation_manager import ConversationManager, ConversationState -import numpy as np -import time +from macbot.conversation_manager import ConversationManager, ConversationState, ResponseState -def test_audio_interruption(): - """Test basic audio interruption functionality""" - print("๐Ÿงช Testing Audio Interruption System...") - # Create audio handler + +def test_audio_interruption(monkeypatch): + """Audio playback can be interrupted.""" handler = AudioInterruptHandler(sample_rate=24000) - # Generate test audio (1 second of 440Hz sine wave) - duration = 1.0 - frequency = 440.0 - t = np.linspace(0, duration, int(24000 * duration), False) - audio = np.sin(frequency * 2 * np.pi * t).astype(np.float32) + def fake_worker(self): + while self.is_playing and not self.interrupt_requested: + time.sleep(0.01) + self.is_playing = False - print("โ–ถ๏ธ Playing test audio...") - success = handler.play_audio(audio) - print(f"โœ… Audio playback {'completed' if success else 'was interrupted'}") + monkeypatch.setattr(AudioInterruptHandler, '_playback_worker', fake_worker) + audio = np.zeros(2400, dtype=np.float32) + result = {} - return success + def run_play(): + result['completed'] = handler.play_audio(audio) -def test_conversation_manager(): - """Test conversation state management""" - print("\n๐Ÿงช Testing Conversation Manager...") + t = threading.Thread(target=run_play) + t.start() + time.sleep(0.05) + handler.interrupt_playback() + t.join() - manager = ConversationManager() + assert result['completed'] is False + status = handler.get_playback_status() + assert not status['is_playing'] + assert status['interrupt_requested'] - # Test state transitions - print("๐Ÿ“ Testing conversation states...") - # Start conversation +def test_conversation_state_transitions(): + """Conversation manager handles state transitions and buffering.""" + manager = ConversationManager() + manager.lock = threading.RLock() conv_id = manager.start_conversation() - print(f"๐Ÿ“Š Started conversation: {conv_id}") - - # Add user input - manager.add_user_input("Hello MacBot") - print("๐Ÿ“ Added user input") + assert manager.current_context.conversation_id == conv_id - # Start response - manager.start_response() - print("๐ŸŽค Started response") + manager.add_user_input('Hello') + manager.start_response('Hi there') + assert manager.current_context.current_state == ConversationState.SPEAKING - # Simulate interruption manager.interrupt_response() - print("โน๏ธ Interrupted response") + assert manager.current_context.current_state == ConversationState.INTERRUPTED - # Check if we can resume buffered = manager.resume_response() - print(f"๐Ÿ“Š Buffered response available: {buffered is not None}") + assert buffered == 'Hi there' - # Complete response manager.complete_response() - print("โœ… Completed response") - - # Check history - history = manager.get_recent_history() - print(f"๐Ÿ“š Conversation history: {len(history)} messages") - - return len(history) > 0 - -def test_integration(): - """Test integrated audio interruption with conversation management""" - print("\n๐Ÿงช Testing Integrated System...") - - # Create components - audio_handler = AudioInterruptHandler(sample_rate=24000) - conversation_manager = ConversationManager() - - # Register callback - def on_state_change(context): - print(f"๐Ÿ”„ State changed to: {context.current_state.value}") - if context.current_state == ConversationState.INTERRUPTED: - audio_handler.interrupt_playback() - - conversation_manager.register_state_callback(on_state_change) - - # Generate longer test audio (3 seconds) - duration = 3.0 - frequency = 440.0 - t = np.linspace(0, duration, int(24000 * duration), False) - audio = np.sin(frequency * 2 * np.pi * t).astype(np.float32) - - print("โ–ถ๏ธ Starting integrated test...") - - # Start conversation - conv_id = conversation_manager.start_conversation() - conversation_manager.add_user_input("Test message") - conversation_manager.start_response() - - # Play audio (should complete normally) - success = audio_handler.play_audio(audio) - - # Complete response - conversation_manager.complete_response() - - print(f"โœ… Integrated test {'completed successfully' if success else 'was interrupted'}") - - return success - -def main(): - """Run all tests""" - print("๐Ÿš€ MacBot Interruptible Conversation System Test Suite") - print("=" * 60) - - try: - # Test individual components - audio_test = test_audio_interruption() - conv_test = test_conversation_manager() - integration_test = test_integration() - - # Summary - print("\n" + "=" * 60) - print("๐Ÿ“Š Test Results:") - print(f" Audio Interruption: {'โœ… PASS' if audio_test else 'โŒ FAIL'}") - print(f" Conversation Manager: {'โœ… PASS' if conv_test else 'โŒ FAIL'}") - print(f" Integration Test: {'โœ… PASS' if integration_test else 'โŒ FAIL'}") - - all_passed = audio_test and conv_test and integration_test - print(f"\n๐ŸŽฏ Overall: {'โœ… ALL TESTS PASSED' if all_passed else 'โŒ SOME TESTS FAILED'}") - - return 0 if all_passed else 1 - - except Exception as e: - print(f"โŒ Test suite failed with error: {e}") - import traceback - traceback.print_exc() - return 1 - -if __name__ == "__main__": - sys.exit(main()) + assert manager.current_context.current_state == ConversationState.IDLE + assert len(manager.get_recent_history()) == 2 diff --git a/tests/test_message_bus.py b/tests/test_message_bus.py index 6f13cfd..ba926f0 100644 --- a/tests/test_message_bus.py +++ b/tests/test_message_bus.py @@ -1,71 +1,63 @@ -#!/usr/bin/env python3 -""" -Test script for MacBot Message Bus System -""" -import sys import os +import sys import time +import pytest + +# Ensure the package can be imported without installation sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'src')) -from macbot.message_bus import MessageBus, start_message_bus, stop_message_bus +from macbot.message_bus import start_message_bus, stop_message_bus +import macbot.message_bus_client as mb_client from macbot.message_bus_client import MessageBusClient -def test_message_bus(): - """Test the message bus system""" - print("๐Ÿงช Testing MacBot Message Bus System...") - # Start message bus - print("1. Starting message bus server...") +@pytest.fixture +def message_bus(): bus = start_message_bus(host='localhost', port=8082) - - # Create test clients - print("2. Creating test clients...") - client1 = MessageBusClient( - host='localhost', - port=8082, - service_type='test_service_1' - ) - client2 = MessageBusClient( - host='localhost', - port=8082, - service_type='test_service_2' - ) - - # Start clients - print("3. Starting clients...") - client1.start() - client2.start() - - # Wait for connections - time.sleep(3) - - # Test message sending - print("4. Testing message exchange...") - - # Client 1 sends message - client1.send_message({ - 'type': 'test_message', - 'content': 'Hello from client 1!', - 'timestamp': time.time() - }) - - # Client 2 sends message - client2.send_message({ - 'type': 'test_message', - 'content': 'Hello from client 2!', - 'timestamp': time.time() - }) - - # Wait for messages to be processed - time.sleep(2) - - print("5. Test completed successfully!") - print("โœ… Message bus system is working properly") - - # Cleanup - client1.stop() - client2.stop() + mb_client.message_bus = bus + yield bus stop_message_bus() -if __name__ == "__main__": - test_message_bus() + +@pytest.fixture +def create_client(message_bus): + clients = [] + + def _factory(service_type: str): + client = MessageBusClient(host='localhost', port=8082, service_type=service_type) + client.start() + clients.append(client) + time.sleep(0.1) # allow connection + return client + + yield _factory + + for c in clients: + c.stop() + + +def test_message_delivery(create_client): + """Clients should receive messages sent on the bus.""" + received = [] + + client2 = create_client('service2') + client2.register_handler('test', lambda m: received.append(m)) + + client1 = create_client('service1') + client1.send_message({'type': 'test', 'content': 'hello'}) + + timeout = time.time() + 2 + while not received and time.time() < timeout: + time.sleep(0.05) + + assert received and received[0]['content'] == 'hello' + + +def test_service_status(message_bus, create_client): + """Message bus should track registered services.""" + client = create_client('network_service') + time.sleep(0.1) + status = message_bus.get_service_status() + assert 'network_service' in status + assert status['network_service']['count'] == 1 + assert client.client_id in status['network_service']['clients'] diff --git a/tests/test_tool_calling.py b/tests/test_tool_calling.py new file mode 100644 index 0000000..34c7ffa --- /dev/null +++ b/tests/test_tool_calling.py @@ -0,0 +1,40 @@ +import os +import sys +import pytest +import types + +# Ensure package import without installation +sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'src')) + +# Provide stubs for optional dependencies used by voice_assistant +sys.modules.setdefault('sounddevice', types.SimpleNamespace()) +sys.modules.setdefault('soundfile', types.SimpleNamespace()) +sys.modules.setdefault('psutil', types.SimpleNamespace()) +sys.modules.setdefault('requests', types.SimpleNamespace(get=lambda *a, **k: None, post=lambda *a, **k: None)) + +from macbot.voice_assistant import ToolCaller, tools + + +def test_tool_caller_web_search(monkeypatch): + caller = ToolCaller() + called = {} + + def fake_search(query): + called['query'] = query + return 'ok' + + monkeypatch.setattr(tools, 'web_search', fake_search) + result = caller.web_search('python') + assert result == 'ok' + assert called['query'] == 'python' + + +def test_tool_caller_error_handling(monkeypatch): + caller = ToolCaller() + + def boom(query): + raise RuntimeError('fail') + + monkeypatch.setattr(tools, 'web_search', boom) + result = caller.web_search('python') + assert "couldn't perform" in result.lower() diff --git a/tests/test_tts_streaming.py b/tests/test_tts_streaming.py new file mode 100644 index 0000000..a6d9dc4 --- /dev/null +++ b/tests/test_tts_streaming.py @@ -0,0 +1,27 @@ +import os +import sys +import pytest + +# Ensure package import without installation +sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'src')) + +from macbot.conversation_manager import ConversationManager, ResponseState +import threading + + +def test_streaming_response_updates_history(): + manager = ConversationManager() + manager.lock = threading.RLock() + manager.start_conversation() + manager.start_response() + + manager.update_response('Hello', is_complete=False) + assert manager.current_context.ai_response == 'Hello' + assert manager.current_context.response_state == ResponseState.STREAMING + + manager.update_response('Hello world', is_complete=True) + assert manager.current_context.ai_response == 'Hello world' + assert manager.current_context.response_state == ResponseState.COMPLETED + + history = manager.get_recent_history() + assert history[-1]['content'] == 'Hello world'