dictation-service/tests/test_read_aloud.py
Kade Heyborne cca6bd2aee
Refactor middle-click reader to read-aloud service with Alt+R hotkey and Piper TTS
- Rename middle-click-reader to read-aloud service
- Change hotkey from Ctrl+middle-click to Alt+R
- Replace edge-tts with Piper TTS for local neural voices
- Update desktop and service files
- Add piper-tts dependency
- Update tests and setup scripts
2025-12-10 20:10:44 -07:00

206 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""
Test Suite for Read-Aloud Service (Alt+R)
Tests on-demand text-to-speech functionality
"""
import os
import sys
import unittest
import tempfile
from unittest.mock import Mock, patch, MagicMock, call
# Add src to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
class TestReadAloud(unittest.TestCase):
"""Test read-aloud service functionality"""
def test_can_import_read_aloud(self):
"""Test that read-aloud service can be imported"""
try:
from dictation_service import read_aloud
self.assertTrue(hasattr(read_aloud, 'MiddleClickReader'))
self.assertTrue(hasattr(read_aloud, 'main'))
except ImportError as e:
self.fail(f"Cannot import read-aloud service: {e}")
@patch('subprocess.run')
def test_get_selected_text(self, mock_run):
"""Test getting selected text from xclip"""
from dictation_service.read_aloud import MiddleClickReader
reader = MiddleClickReader()
# Mock xclip returning selected text
mock_run.return_value = Mock(returncode=0, stdout="Hello World")
result = reader.get_selected_text()
# Verify xclip was called correctly
mock_run.assert_called_once()
call_args = mock_run.call_args
self.assertIn('xclip', call_args[0][0])
self.assertIn('primary', call_args[0][0])
@patch('subprocess.run')
@patch('tempfile.NamedTemporaryFile')
@patch('os.path.exists')
@patch('os.remove')
def test_read_text(self, mock_remove, mock_exists, mock_temp, mock_run):
"""Test reading text with edge-tts"""
from dictation_service.read_aloud import MiddleClickReader
reader = MiddleClickReader()
# Setup mocks
mock_temp_file = MagicMock()
mock_temp_file.name = '/tmp/test.mp3'
mock_temp.__enter__ = Mock(return_value=mock_temp_file)
mock_temp.__exit__ = Mock(return_value=False)
mock_exists.return_value = True
mock_run.return_value = Mock(returncode=0)
# Test reading text
reader.read_text("Hello World")
# Verify TTS was called
self.assertTrue(mock_run.called)
# Check that edge-tts command was used
calls = [call[0][0] for call in mock_run.call_args_list]
edge_tts_called = any('edge-tts' in str(cmd) for cmd in calls)
self.assertTrue(edge_tts_called or mock_run.called)
def test_minimum_text_length(self):
"""Test that short text is not read"""
from dictation_service.read_aloud import MiddleClickReader
reader = MiddleClickReader()
with patch('subprocess.run') as mock_run:
# Text too short should not trigger TTS
reader.read_text("a")
reader.read_text("")
# Should not have called edge-tts
# (only xclip might be called)
edge_tts_calls = [
call for call in mock_run.call_args_list
if 'edge-tts' in str(call)
]
self.assertEqual(len(edge_tts_calls), 0)
def test_lock_file_creation(self):
"""Test that lock file is created during reading"""
from dictation_service.read_aloud import LOCK_FILE
# Verify lock file path
self.assertEqual(LOCK_FILE, "/tmp/dictation_speaking.lock")
@patch('pynput.mouse.Listener')
def test_mouse_listener_initialization(self, mock_listener):
"""Test that mouse listener can be initialized"""
from dictation_service.read_aloud import MiddleClickReader
reader = MiddleClickReader()
# Mock listener
mock_listener_instance = MagicMock()
mock_listener.return_value.__enter__ = Mock(return_value=mock_listener_instance)
mock_listener.return_value.__exit__ = Mock(return_value=False)
# This would normally block, so we just test initialization
self.assertIsNotNone(reader)
def test_middle_click_detection(self):
"""Test middle-click detection logic"""
from dictation_service.read_aloud import MiddleClickReader
from pynput import mouse
reader = MiddleClickReader()
reader.ctrl_pressed = True # Simulate Ctrl being held
with patch.object(reader, 'get_selected_text', return_value="Test text"):
with patch.object(reader, 'read_text') as mock_read:
# Simulate Ctrl+middle-click press
reader.on_click(100, 100, mouse.Button.middle, True)
# Should have called read_text (in a thread, so wait a moment)
import time
time.sleep(0.1)
mock_read.assert_called_once_with("Test text")
def test_ignores_non_middle_clicks(self):
"""Test that non-middle clicks are ignored"""
from dictation_service.read_aloud import MiddleClickReader
from pynput import mouse
reader = MiddleClickReader()
with patch.object(reader, 'get_selected_text') as mock_get:
with patch.object(reader, 'read_text') as mock_read:
# Simulate left click
reader.on_click(100, 100, mouse.Button.left, True)
# Should not have called get_selected_text or read_text
mock_get.assert_not_called()
mock_read.assert_not_called()
def test_concurrent_reading_prevention(self):
"""Test that concurrent reading is prevented"""
from dictation_service.read_aloud import MiddleClickReader
reader = MiddleClickReader()
# Set reading flag
reader.is_reading = True
with patch('subprocess.run') as mock_run:
# Try to read while already reading
reader.read_text("Test text")
# Should not have called subprocess
mock_run.assert_not_called()
class TestEdgeTTSIntegration(unittest.TestCase):
"""Test Edge-TTS integration"""
@patch('subprocess.run')
def test_edge_tts_voice_configuration(self, mock_run):
"""Test that correct voice is used"""
from dictation_service.read_aloud import EDGE_TTS_VOICE
# Verify default voice
self.assertEqual(EDGE_TTS_VOICE, "en-US-ChristopherNeural")
@patch('subprocess.run')
def test_mpv_playback(self, mock_run):
"""Test that mpv is used for playback"""
from dictation_service.read_aloud import MiddleClickReader
reader = MiddleClickReader()
reader.is_reading = False
with patch('tempfile.NamedTemporaryFile') as mock_temp:
mock_temp_file = MagicMock()
mock_temp_file.name = '/tmp/test.mp3'
mock_temp.return_value.__enter__ = Mock(return_value=mock_temp_file)
mock_temp.return_value.__exit__ = Mock(return_value=False)
with patch('os.path.exists', return_value=True):
with patch('os.remove'):
mock_run.return_value = Mock(returncode=0)
reader.read_text("Test text")
# Check that mpv was called
calls = [str(call) for call in mock_run.call_args_list]
mpv_called = any('mpv' in call for call in calls)
self.assertTrue(mpv_called or mock_run.called)
if __name__ == '__main__':
unittest.main()