Test Structure Reorganization and CI Improvements (#5)

* Add GitHub Actions CI/CD workflows and status badges

* Fix CI workflow dependency installation

* Fix CI workflow: remove pip caching and add explicit requirements.txt

* Fix CI workflow: add system dependencies for PyGObject and pycairo

* Refactor code for improved readability and consistency

- Cleaned up whitespace and formatting in audio_feedback.py, config_manager.py, keyboard_shortcuts.py, tray_indicator.py, run_basic_tests.py, and test_basic.py.
- Enhanced the structure of the code by aligning comments and ensuring consistent indentation.
- Updated function definitions and calls to maintain a uniform style across the codebase.
- Improved logging messages for better clarity during debugging.
- Ensured all files end with a newline for better compatibility with various tools.

* Refactor imports for consistency and clarity across multiple modules

* Fix test imports by setting PYTHONPATH and adding conftest.py

* Update CI workflow to use Python 3.12 and ensure proper import path in tests

* Fix CI: Add GObject Introspection development dependencies

* Update CI: Use virtual environment like local setup

* Fix CI

* Update README and setup.py: Change Python version to 3.8 and correct author email

* Restructure tests for better organization and maintainability

* Fix formatting

* Update CI workflow to remove redundant test step

* Fix CI tests by properly mocking VOSK model loading

* Update CI and Tests

* Simplify
This commit is contained in:
Jatin K Malik
2025-04-16 20:23:40 -05:00
committed by GitHub
parent 7590332598
commit 3645c692fd
20 changed files with 996 additions and 682 deletions
+13
View File
@@ -0,0 +1,13 @@
"""
Configuration file for pytest.
This file makes sure that the 'src' module can be imported in tests.
"""
import os
import sys
# Add the parent directory to sys.path so that 'src' can be imported
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
# This will help pytest discover all test files correctly
pytest_plugins = []
-229
View File
@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""
Basic test script for Vocalinux.
This script performs basic tests of individual components
without requiring complex dependencies like GTK or VOSK.
"""
import os
import sys
import unittest
import tempfile
import json
from unittest.mock import MagicMock, patch
print("Starting basic tests for Vocalinux")
# Add project root to Python path
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
sys.path.insert(0, project_root)
# Test TextInjector class with mocked dependencies
class TextInjectorTests(unittest.TestCase):
"""Basic tests for the TextInjector class."""
@patch('shutil.which')
@patch('os.environ.get')
def test_environment_detection_x11(self, mock_environ_get, mock_which):
"""Test environment detection for X11."""
# Import here to avoid loading all dependencies
from src.text_injection.text_injector import TextInjector, DesktopEnvironment
# Mock environment variables for X11
mock_environ_get.return_value = "x11"
mock_which.return_value = "/usr/bin/xdotool"
# Create injector with mocked environment
injector = TextInjector()
# Check environment detection
self.assertEqual(injector.environment, DesktopEnvironment.X11)
print("✓ Environment detection for X11 works correctly")
@patch('shutil.which')
@patch('os.environ.get')
def test_environment_detection_wayland(self, mock_environ_get, mock_which):
"""Test environment detection for Wayland."""
# Import here to avoid loading all dependencies
from src.text_injection.text_injector import TextInjector, DesktopEnvironment
# Mock environment variables for Wayland
mock_environ_get.return_value = "wayland"
mock_which.return_value = "/usr/bin/wtype"
# Create injector with mocked environment
injector = TextInjector()
# Check environment detection
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
print("✓ Environment detection for Wayland works correctly")
# Test command processor with mocked dependencies
class CommandProcessorTests(unittest.TestCase):
"""Basic tests for the CommandProcessor class."""
def test_command_processing(self):
"""Test basic command processing functionality."""
# Import command processor
from src.speech_recognition.command_processor import CommandProcessor
# Create processor
processor = CommandProcessor()
# Test text commands (punctuation, etc.)
text, actions = processor.process_text("add a period to the end")
self.assertEqual(text, "add a . to the end")
self.assertEqual(actions, [])
print("✓ Basic text command processing works correctly")
# Test action commands
text, actions = processor.process_text("delete that please")
# Adjust expected text to match actual behavior
self.assertEqual(text, "please")
self.assertEqual(actions, ["delete_last"])
print("✓ Action command processing works correctly")
# Test format commands
text, actions = processor.process_text("capitalize hello")
self.assertEqual(text, "Hello")
self.assertEqual(actions, [])
print("✓ Format command processing works correctly")
# Test config manager with a temporary test directory
class ConfigManagerTests(unittest.TestCase):
"""Basic tests for the ConfigManager class."""
def setUp(self):
"""Set up a temporary config directory for testing."""
# Create temp directory for configs
self.temp_dir = tempfile.TemporaryDirectory()
# Patch the CONFIG_DIR constant
self.patcher = patch('src.ui.config_manager.CONFIG_DIR', self.temp_dir.name)
self.patcher.start()
# Patch the CONFIG_FILE constant
config_file = os.path.join(self.temp_dir.name, "config.json")
self.file_patcher = patch('src.ui.config_manager.CONFIG_FILE', config_file)
self.file_patcher.start()
def tearDown(self):
"""Clean up after tests."""
self.patcher.stop()
self.file_patcher.stop()
self.temp_dir.cleanup()
def test_config_defaults(self):
"""Test that default configuration values are set correctly."""
# Import config manager
from src.ui.config_manager import ConfigManager, DEFAULT_CONFIG
# Create config manager
config = ConfigManager()
# Test default values
self.assertEqual(config.get("recognition", "engine"), "vosk")
self.assertEqual(config.get("shortcuts", "toggle_recognition"), "alt+shift+v")
self.assertEqual(config.get("ui", "show_notifications"), True)
print("✓ Default configuration values are correct")
def test_set_and_get_config(self):
"""Test setting and getting configuration values."""
# Import config manager
from src.ui.config_manager import ConfigManager
# Create config manager
config = ConfigManager()
# Test setting and getting a value
self.assertTrue(config.set("recognition", "engine", "whisper"))
self.assertEqual(config.get("recognition", "engine"), "whisper")
# Test saving and reloading
self.assertTrue(config.save_config())
# Create a new instance to load from file
config2 = ConfigManager()
self.assertEqual(config2.get("recognition", "engine"), "whisper")
print("✓ Setting, saving, and loading configuration works correctly")
# Simplified test for keyboard shortcuts that doesn't require the pynput module directly
class SimpleKeyboardShortcutTests(unittest.TestCase):
"""Simplified tests for keyboard shortcut functionality without direct pynput imports."""
def test_shortcut_parsing(self):
"""Test that keyboard shortcut parsing works correctly."""
# Define a mock KeyboardShortcutManager class to avoid importing pynput
class MockKeyboardShortcutManager:
def __init__(self):
self.shortcuts = {}
self.current_keys = set()
def parse_shortcut(self, shortcut_str):
"""Parse a shortcut string into a list of key identifiers."""
return shortcut_str.lower().split('+')
def register_shortcut(self, name, shortcut, callback):
"""Register a keyboard shortcut."""
self.shortcuts[name] = {
"keys": self.parse_shortcut(shortcut),
"callback": callback
}
return True
# Create an instance of our mock manager
shortcut_manager = MockKeyboardShortcutManager()
# Test shortcut parsing
shortcut_manager.register_shortcut("test", "ctrl+shift+t", lambda: None)
self.assertIn("test", shortcut_manager.shortcuts)
self.assertEqual(shortcut_manager.shortcuts["test"]["keys"], ["ctrl", "shift", "t"])
print("✓ Keyboard shortcut parsing works correctly")
# Test multiple shortcut registration
callback_called = False
def test_callback():
nonlocal callback_called
callback_called = True
shortcut_manager.register_shortcut("toggle_dictation", "alt+shift+v", test_callback)
self.assertIn("toggle_dictation", shortcut_manager.shortcuts)
self.assertEqual(shortcut_manager.shortcuts["toggle_dictation"]["keys"], ["alt", "shift", "v"])
self.assertEqual(shortcut_manager.shortcuts["toggle_dictation"]["callback"], test_callback)
print("✓ Multiple keyboard shortcuts can be registered")
# Mock test for the detection algorithm - without the dependency
def test_shortcut_detection_logic(self):
"""Test the logic for detecting keyboard shortcuts."""
# Define a simple shortcut detection algorithm
def check_shortcut_triggered(current_keys, shortcut_keys):
"""Check if the currently pressed keys match a shortcut."""
return set(shortcut_keys).issubset(set(current_keys)) and len(current_keys) == len(shortcut_keys)
# Test detection with various key combinations
# Case 1: All keys in the shortcut are pressed
current_keys = ["alt", "shift", "v"]
shortcut_keys = ["alt", "shift", "v"]
self.assertTrue(check_shortcut_triggered(current_keys, shortcut_keys))
# Case 2: Some keys in the shortcut are pressed
current_keys = ["alt", "v"]
shortcut_keys = ["alt", "shift", "v"]
self.assertFalse(check_shortcut_triggered(current_keys, shortcut_keys))
# Case 3: Extra keys are pressed
current_keys = ["alt", "shift", "v", "ctrl"]
shortcut_keys = ["alt", "shift", "v"]
self.assertFalse(check_shortcut_triggered(current_keys, shortcut_keys))
# Case 4: Different shortcut is pressed
current_keys = ["ctrl", "shift", "t"]
shortcut_keys = ["alt", "shift", "v"]
self.assertFalse(check_shortcut_triggered(current_keys, shortcut_keys))
print("✓ Keyboard shortcut detection logic works correctly")
if __name__ == "__main__":
unittest.main()
-77
View File
@@ -1,77 +0,0 @@
"""
Basic tests for the Vocalinux application.
"""
import unittest
from unittest.mock import MagicMock, patch
from src.speech_recognition.recognition_manager import SpeechRecognitionManager, RecognitionState
from src.text_injection.text_injector import TextInjector, DesktopEnvironment
class TestSpeechRecognition(unittest.TestCase):
"""Test cases for the speech recognition manager."""
@patch('src.speech_recognition.recognition_manager.os.makedirs')
def test_init_state(self, mock_makedirs):
"""Test the initial state of the speech recognition manager."""
manager = SpeechRecognitionManager(engine="vosk", model_size="small")
self.assertEqual(manager.state, RecognitionState.IDLE)
self.assertEqual(manager.engine, "vosk")
self.assertEqual(manager.model_size, "small")
@patch('src.speech_recognition.recognition_manager.os.makedirs')
def test_callbacks(self, mock_makedirs):
"""Test callback registration and invocation."""
manager = SpeechRecognitionManager(engine="vosk", model_size="small")
# Mock callbacks
text_callback = MagicMock()
state_callback = MagicMock()
# Register callbacks
manager.register_text_callback(text_callback)
manager.register_state_callback(state_callback)
# Verify callbacks were registered
self.assertIn(text_callback, manager.text_callbacks)
self.assertIn(state_callback, manager.state_callbacks)
# Test state update
manager._update_state(RecognitionState.LISTENING)
state_callback.assert_called_once_with(RecognitionState.LISTENING)
class TestTextInjection(unittest.TestCase):
"""Test cases for the text injector."""
@patch('src.text_injection.text_injector.shutil.which')
@patch('src.text_injection.text_injector.TextInjector._detect_environment')
def test_init_x11(self, mock_detect, mock_which):
"""Test initialization in X11 environment."""
mock_detect.return_value = DesktopEnvironment.X11
mock_which.return_value = '/usr/bin/xdotool' # Simulate xdotool exists
injector = TextInjector()
self.assertEqual(injector.environment, DesktopEnvironment.X11)
@patch('src.text_injection.text_injector.subprocess.run')
@patch('src.text_injection.text_injector.shutil.which')
@patch('src.text_injection.text_injector.TextInjector._detect_environment')
def test_inject_text_x11(self, mock_detect, mock_which, mock_run):
"""Test text injection in X11 environment."""
mock_detect.return_value = DesktopEnvironment.X11
mock_which.return_value = '/usr/bin/xdotool'
injector = TextInjector()
injector.inject_text("Hello, world!")
mock_run.assert_called_once()
args, kwargs = mock_run.call_args
self.assertEqual(args[0][0], "xdotool")
self.assertEqual(args[0][2], "--clearmodifiers")
self.assertEqual(args[0][3], "Hello, world!")
if __name__ == '__main__':
unittest.main()
+35
View File
@@ -0,0 +1,35 @@
"""
Tests for the CommandProcessor component.
"""
import unittest
from unittest.mock import MagicMock, patch
import pytest
from src.speech_recognition.command_processor import CommandProcessor
class TestCommandProcessor(unittest.TestCase):
"""Test cases for the command processor."""
def test_command_processing(self):
"""Test basic command processing functionality."""
# Create processor
processor = CommandProcessor()
# Test text commands (punctuation, etc.)
text, actions = processor.process_text("add a period to the end")
assert text == "add a . to the end"
assert actions == []
# Test action commands
text, actions = processor.process_text("delete that please")
# Adjust expected text to match actual behavior
assert text == "please"
assert actions == ["delete_last"]
# Test format commands
text, actions = processor.process_text("capitalize hello")
assert text == "Hello"
assert actions == []
+62
View File
@@ -0,0 +1,62 @@
"""
Tests for the ConfigManager component.
"""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import pytest
from src.ui.config_manager import ConfigManager
class TestConfigManager(unittest.TestCase):
"""Test cases for the config manager."""
def setUp(self):
"""Set up a temporary config directory for testing."""
# Create temp directory for configs
self.temp_dir = tempfile.TemporaryDirectory()
# Patch the CONFIG_DIR constant
self.patcher = patch("src.ui.config_manager.CONFIG_DIR", self.temp_dir.name)
self.patcher.start()
# Patch the CONFIG_FILE constant
config_file = os.path.join(self.temp_dir.name, "config.json")
self.file_patcher = patch("src.ui.config_manager.CONFIG_FILE", config_file)
self.file_patcher.start()
def tearDown(self):
"""Clean up after tests."""
self.patcher.stop()
self.file_patcher.stop()
self.temp_dir.cleanup()
def test_config_defaults(self):
"""Test that default configuration values are set correctly."""
# Create config manager
config = ConfigManager()
# Test default values
assert config.get("recognition", "engine") == "vosk"
assert config.get("shortcuts", "toggle_recognition") == "alt+shift+v"
assert config.get("ui", "show_notifications") is True
def test_set_and_get_config(self):
"""Test setting and getting configuration values."""
# Create config manager
config = ConfigManager()
# Test setting and getting a value
assert config.set("recognition", "engine", "whisper") is True
assert config.get("recognition", "engine") == "whisper"
# Test saving and reloading
assert config.save_config() is True
# Create a new instance to load from file
config2 = ConfigManager()
assert config2.get("recognition", "engine") == "whisper"
+93
View File
@@ -0,0 +1,93 @@
"""
Tests for the keyboard shortcuts functionality.
"""
import unittest
from unittest.mock import MagicMock, patch
import pytest
class TestKeyboardShortcuts(unittest.TestCase):
"""Test cases for keyboard shortcuts functionality."""
def test_shortcut_parsing(self):
"""Test that keyboard shortcut parsing works correctly."""
# Define a mock KeyboardShortcutManager class to avoid importing pynput
class MockKeyboardShortcutManager:
def __init__(self):
self.shortcuts = {}
self.current_keys = set()
def parse_shortcut(self, shortcut_str):
"""Parse a shortcut string into a list of key identifiers."""
return shortcut_str.lower().split("+")
def register_shortcut(self, name, shortcut, callback):
"""Register a keyboard shortcut."""
self.shortcuts[name] = {
"keys": self.parse_shortcut(shortcut),
"callback": callback,
}
return True
# Create an instance of our mock manager
shortcut_manager = MockKeyboardShortcutManager()
# Test shortcut parsing
shortcut_manager.register_shortcut("test", "ctrl+shift+t", lambda: None)
assert "test" in shortcut_manager.shortcuts
assert shortcut_manager.shortcuts["test"]["keys"] == ["ctrl", "shift", "t"]
# Test multiple shortcut registration
callback_called = False
def test_callback():
nonlocal callback_called
callback_called = True
shortcut_manager.register_shortcut(
"toggle_dictation", "alt+shift+v", test_callback
)
assert "toggle_dictation" in shortcut_manager.shortcuts
assert shortcut_manager.shortcuts["toggle_dictation"]["keys"] == [
"alt",
"shift",
"v",
]
assert (
shortcut_manager.shortcuts["toggle_dictation"]["callback"] == test_callback
)
# Mock test for the detection algorithm - without the dependency
def test_shortcut_detection_logic(self):
"""Test the logic for detecting keyboard shortcuts."""
# Define a simple shortcut detection algorithm
def check_shortcut_triggered(current_keys, shortcut_keys):
"""Check if the currently pressed keys match a shortcut."""
return set(shortcut_keys).issubset(set(current_keys)) and len(
current_keys
) == len(shortcut_keys)
# Test detection with various key combinations
# Case 1: All keys in the shortcut are pressed
current_keys = ["alt", "shift", "v"]
shortcut_keys = ["alt", "shift", "v"]
assert check_shortcut_triggered(current_keys, shortcut_keys) is True
# Case 2: Some keys in the shortcut are pressed
current_keys = ["alt", "v"]
shortcut_keys = ["alt", "shift", "v"]
assert check_shortcut_triggered(current_keys, shortcut_keys) is False
# Case 3: Extra keys are pressed
current_keys = ["alt", "shift", "v", "ctrl"]
shortcut_keys = ["alt", "shift", "v"]
assert check_shortcut_triggered(current_keys, shortcut_keys) is False
# Case 4: Different shortcut is pressed
current_keys = ["ctrl", "shift", "t"]
shortcut_keys = ["alt", "shift", "v"]
assert check_shortcut_triggered(current_keys, shortcut_keys) is False
+66
View File
@@ -0,0 +1,66 @@
"""
Tests for the SpeechRecognitionManager component.
"""
import unittest
from unittest.mock import MagicMock, patch
import pytest
from src.speech_recognition.recognition_manager import (
RecognitionState,
SpeechRecognitionManager,
)
class TestSpeechRecognition(unittest.TestCase):
"""Test cases for the speech recognition manager."""
@patch("src.speech_recognition.recognition_manager.os.path.exists")
@patch("src.speech_recognition.recognition_manager.os.makedirs")
@patch(
"src.speech_recognition.recognition_manager.SpeechRecognitionManager._get_vosk_model_path"
)
def test_init_state(self, mock_get_model_path, mock_makedirs, mock_exists):
"""Test the initial state of the speech recognition manager."""
# Mock the file existence check and model path
mock_exists.return_value = True
mock_get_model_path.return_value = "/fake/path/to/model"
# We need to patch the import of vosk to avoid errors
with patch.dict("sys.modules", {"vosk": MagicMock()}):
manager = SpeechRecognitionManager(engine="vosk", model_size="small")
assert manager.state == RecognitionState.IDLE
assert manager.engine == "vosk"
assert manager.model_size == "small"
@patch("src.speech_recognition.recognition_manager.os.path.exists")
@patch("src.speech_recognition.recognition_manager.os.makedirs")
@patch(
"src.speech_recognition.recognition_manager.SpeechRecognitionManager._get_vosk_model_path"
)
def test_callbacks(self, mock_get_model_path, mock_makedirs, mock_exists):
"""Test callback registration and invocation."""
# Mock the file existence check and model path
mock_exists.return_value = True
mock_get_model_path.return_value = "/fake/path/to/model"
# We need to patch the import of vosk to avoid errors
with patch.dict("sys.modules", {"vosk": MagicMock()}):
manager = SpeechRecognitionManager(engine="vosk", model_size="small")
# Mock callbacks
text_callback = MagicMock()
state_callback = MagicMock()
# Register callbacks
manager.register_text_callback(text_callback)
manager.register_state_callback(state_callback)
# Verify callbacks were registered
assert text_callback in manager.text_callbacks
assert state_callback in manager.state_callbacks
# Test state update
manager._update_state(RecognitionState.LISTENING)
state_callback.assert_called_once_with(RecognitionState.LISTENING)
+80
View File
@@ -0,0 +1,80 @@
"""
Tests for the TextInjector component.
"""
import unittest
from unittest.mock import MagicMock, patch
import pytest
from src.text_injection.text_injector import DesktopEnvironment, TextInjector
class TestTextInjector(unittest.TestCase):
"""Test cases for the text injector."""
@patch("src.text_injection.text_injector.shutil.which")
@patch("src.text_injection.text_injector.TextInjector._detect_environment")
def test_init_x11(self, mock_detect, mock_which):
"""Test initialization in X11 environment."""
mock_detect.return_value = DesktopEnvironment.X11
mock_which.return_value = "/usr/bin/xdotool" # Simulate xdotool exists
injector = TextInjector()
assert injector.environment == DesktopEnvironment.X11
@patch("src.text_injection.text_injector.subprocess.run")
@patch("src.text_injection.text_injector.shutil.which")
@patch("src.text_injection.text_injector.TextInjector._detect_environment")
def test_inject_text_x11(self, mock_detect, mock_which, mock_run):
"""Test text injection in X11 environment."""
mock_detect.return_value = DesktopEnvironment.X11
mock_which.return_value = "/usr/bin/xdotool"
injector = TextInjector()
injector.inject_text("Hello, world!")
# The TextInjector makes two subprocess calls:
# 1. To type the text
# 2. To press Escape to exit insertion mode
assert mock_run.call_count == 2
first_call_args = mock_run.call_args_list[0][0][0]
second_call_args = mock_run.call_args_list[1][0][0]
# Verify first call types the text
assert first_call_args[0] == "xdotool"
assert first_call_args[2] == "--clearmodifiers"
assert first_call_args[3] == "Hello, world!"
# Verify second call presses Escape
assert second_call_args[0] == "xdotool"
assert second_call_args[2] == "--clearmodifiers"
assert second_call_args[3] == "Escape"
@patch("shutil.which")
@patch("os.environ.get")
def test_environment_detection_x11(self, mock_environ_get, mock_which):
"""Test environment detection for X11."""
# Mock environment variables for X11
mock_environ_get.return_value = "x11"
mock_which.return_value = "/usr/bin/xdotool"
# Create injector with mocked environment
injector = TextInjector()
# Check environment detection
assert injector.environment == DesktopEnvironment.X11
@patch("shutil.which")
@patch("os.environ.get")
def test_environment_detection_wayland(self, mock_environ_get, mock_which):
"""Test environment detection for Wayland."""
# Mock environment variables for Wayland
mock_environ_get.return_value = "wayland"
mock_which.return_value = "/usr/bin/wtype"
# Create injector with mocked environment
injector = TextInjector()
# Check environment detection
assert injector.environment == DesktopEnvironment.WAYLAND