diff --git a/tests/test_audio_player.py b/tests/test_audio_player.py index c7a6a90..64d84f9 100644 --- a/tests/test_audio_player.py +++ b/tests/test_audio_player.py @@ -14,7 +14,7 @@ def test_init_default_volume(self): """AudioPlayer should initialize with default 50% volume.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer() assert player.get_volume() == 50 @@ -22,7 +22,7 @@ def test_init_custom_volume(self): """AudioPlayer should accept custom initial volume.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer(volume_percent=75) assert player.get_volume() == 75 @@ -30,7 +30,7 @@ def test_init_volume_clamped_high(self): """Volume above 100 should be clamped to 100.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer(volume_percent=150) assert player.get_volume() == 100 @@ -38,19 +38,73 @@ def test_init_volume_clamped_low(self): """Volume below 0 should be clamped to 0.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer(volume_percent=-50) assert player.get_volume() == 0 + def test_init_with_sound_lib_initializes_bass(self): + """AudioPlayer should init BASS when sound_lib is available.""" + import accessiclock.audio.player as player_module + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + try: + player_module._use_sound_lib = True + player_module._bass_initialized = False + + mock_output = MagicMock() + with ( + patch.dict( + "sys.modules", + {"sound_lib.output": mock_output, "sound_lib": MagicMock()}, + ), + patch( + "accessiclock.audio.player.output", mock_output, create=True + ), + ): + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._current_stream = None + player._volume = 50 + assert player is not None + + # Reset + player_module._bass_initialized = False + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init + + def test_init_bass_already_initialized(self): + """AudioPlayer should skip BASS init if already done.""" + import accessiclock.audio.player as player_module + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + try: + player_module._use_sound_lib = True + player_module._bass_initialized = True # Already init'd + + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._current_stream = None + player._volume = 50 + # No error since BASS already initialized + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init + class TestVolumeControl: """Test volume control methods.""" - @pytest.fixture + @pytest.fixture() def player(self): """Create an AudioPlayer with mocked backend.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer + return AudioPlayer(volume_percent=50) def test_set_volume(self, player): @@ -74,6 +128,55 @@ def test_volume_decimal_conversion(self, player): assert player._convert_volume_to_decimal(50) == 0.5 assert player._convert_volume_to_decimal(100) == 1.0 + def test_set_volume_updates_playing_stream(self): + """set_volume should update volume on currently playing stream.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = True + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + + mock_stream = MagicMock() + mock_stream.is_playing = True + player._current_stream = mock_stream + + player.set_volume(80) + assert player.get_volume() == 80 + assert mock_stream.volume == 0.8 + finally: + player_module._use_sound_lib = original_use + + def test_set_volume_no_update_when_not_playing(self): + """set_volume should not update stream volume if not playing.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = True + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + + # Use a non-Mock object to verify volume is not set + class FakeStream: + is_playing = False + volume = 0.5 # original value + + fake_stream = FakeStream() + player._current_stream = fake_stream + + player.set_volume(80) + assert player.get_volume() == 80 + # Volume should NOT be updated since stream is not playing + assert fake_stream.volume == 0.5 + finally: + player_module._use_sound_lib = original_use + class TestPlaySound: """Test sound playback methods.""" @@ -82,39 +185,156 @@ def test_play_nonexistent_file_raises(self): """Playing a nonexistent file should raise FileNotFoundError.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer() with pytest.raises(FileNotFoundError): player.play_sound("/nonexistent/path/to/audio.wav") - def test_play_sound_with_fallback(self): + def test_play_sound_dispatches_to_sound_lib(self): + """play_sound should use sound_lib when available.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = True + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + player._play_with_sound_lib = MagicMock() + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + temp_path = f.name + try: + player.play_sound(temp_path) + player._play_with_sound_lib.assert_called_once() + finally: + Path(temp_path).unlink(missing_ok=True) + finally: + player_module._use_sound_lib = original_use + + def test_play_sound_dispatches_to_fallback(self): """play_sound should use fallback when sound_lib unavailable.""" - # Skip if playsound3 not available + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib try: - import playsound3 # noqa: F401 - playsound_available = True - except ImportError: - playsound_available = False - - if not playsound_available: - pytest.skip("playsound3 not available") - - with patch("accessiclock.audio.player._use_sound_lib", False): - from accessiclock.audio.player import AudioPlayer - - player = AudioPlayer() - - # Create a temporary file + player_module._use_sound_lib = False + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + player._play_with_fallback = MagicMock() + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: temp_path = f.name - try: - # Mock playsound in the module where it's used - import accessiclock.audio.player as player_module - with patch.object(player_module, "playsound", create=True): - player.play_sound(temp_path) + player.play_sound(temp_path) + player._play_with_fallback.assert_called_once() finally: Path(temp_path).unlink(missing_ok=True) + finally: + player_module._use_sound_lib = original_use + + def test_play_with_fallback_uses_playsound3(self): + """_play_with_fallback should use playsound3 in a thread.""" + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + mock_playsound = MagicMock() + mock_thread_class = MagicMock() + mock_thread_instance = MagicMock() + mock_thread_class.return_value = mock_thread_instance + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + temp_path = f.name + try: + with ( + patch( + "accessiclock.audio.player.playsound", + mock_playsound, + create=True, + ), + patch("threading.Thread", mock_thread_class), + ): + player._play_with_fallback(Path(temp_path)) + mock_thread_class.assert_called_once() + mock_thread_instance.start.assert_called_once() + finally: + Path(temp_path).unlink(missing_ok=True) + + def test_play_with_fallback_import_error(self): + """_play_with_fallback should raise ImportError when playsound3 missing.""" + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + temp_path = f.name + try: + with ( + patch( + "builtins.__import__", + side_effect=ImportError("No module named 'playsound3'"), + ), + pytest.raises(ImportError), + ): + player._play_with_fallback(Path(temp_path)) + finally: + Path(temp_path).unlink(missing_ok=True) + + def test_play_with_fallback_generic_error(self): + """_play_with_fallback should raise on generic errors.""" + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + temp_path = f.name + try: + mock_playsound = MagicMock() + with ( + patch("threading.Thread", side_effect=RuntimeError("thread error")), + patch.dict("sys.modules", {"playsound3": MagicMock(playsound=mock_playsound)}), + pytest.raises(RuntimeError, match="thread error"), + ): + player._play_with_fallback(Path(temp_path)) + finally: + Path(temp_path).unlink(missing_ok=True) + + def test_play_with_sound_lib_error(self): + """_play_with_sound_lib should raise on stream errors.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + mock_stream_module = MagicMock() + mock_stream_module.FileStream.side_effect = RuntimeError("stream error") + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + temp_path = f.name + try: + with ( + patch.object( + player_module, "stream", mock_stream_module, create=True + ), + pytest.raises(RuntimeError, match="stream error"), + ): + player._play_with_sound_lib(Path(temp_path)) + finally: + Path(temp_path).unlink(missing_ok=True) class TestIsPlaying: @@ -124,10 +344,111 @@ def test_is_playing_initially_false(self): """is_playing should return False when nothing is playing.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer() assert player.is_playing() is False + def test_is_playing_exception_returns_false(self): + """is_playing should return False when stream raises exception.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = True + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + + mock_stream = MagicMock() + type(mock_stream).is_playing = property( + lambda self: (_ for _ in ()).throw(RuntimeError("error")) + ) + player._current_stream = mock_stream + + assert player.is_playing() is False + finally: + player_module._use_sound_lib = original_use + + def test_is_playing_no_sound_lib_returns_false(self): + """is_playing returns False when sound_lib is not used.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = False + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = MagicMock() # Even with a stream + + assert player.is_playing() is False + finally: + player_module._use_sound_lib = original_use + + +class TestStop: + """Test stop method.""" + + def test_stop_no_stream(self): + """stop should be safe when no stream exists.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = True + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + # Should not raise + player.stop() + finally: + player_module._use_sound_lib = original_use + + def test_stop_exception_clears_stream(self): + """stop should clear stream reference even on error.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = True + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + + mock_stream = MagicMock() + mock_stream.stop.side_effect = RuntimeError("stop error") + player._current_stream = mock_stream + + player.stop() + assert player._current_stream is None + finally: + player_module._use_sound_lib = original_use + + def test_stop_not_sound_lib(self): + """stop should be no-op when sound_lib not used.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + try: + player_module._use_sound_lib = False + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = MagicMock() + + player.stop() + # Stream not touched since sound_lib not used + assert player._current_stream is not None + finally: + player_module._use_sound_lib = original_use + class TestCleanup: """Test cleanup method.""" @@ -136,7 +457,7 @@ def test_cleanup_no_error_when_nothing_playing(self): """cleanup should not raise when nothing is playing.""" with patch("accessiclock.audio.player._use_sound_lib", False): from accessiclock.audio.player import AudioPlayer - + player = AudioPlayer() # Should not raise player.cleanup() @@ -144,30 +465,119 @@ def test_cleanup_no_error_when_nothing_playing(self): def test_cleanup_stops_playback(self): """cleanup should stop any current playback.""" from accessiclock.audio.player import AudioPlayer - + # Create player instance directly with mocked stream player = AudioPlayer.__new__(AudioPlayer) player._volume = 50 mock_current = MagicMock() player._current_stream = mock_current - + # Mock cleanup to avoid BASS_Free issues import accessiclock.audio.player as player_module + original_use_sound_lib = player_module._use_sound_lib original_bass_init = player_module._bass_initialized - + try: player_module._use_sound_lib = True player_module._bass_initialized = False # Skip BASS_Free - + player.cleanup() - + mock_current.stop.assert_called_once() mock_current.free.assert_called_once() finally: player_module._use_sound_lib = original_use_sound_lib player_module._bass_initialized = original_bass_init + def test_cleanup_stream_error_suppressed(self): + """cleanup should suppress stream stop/free errors.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + + try: + player_module._use_sound_lib = False + player_module._bass_initialized = False + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + + mock_stream = MagicMock() + mock_stream.stop.side_effect = RuntimeError("cleanup error") + player._current_stream = mock_stream + + # Should not raise + player.cleanup() + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init + + def test_cleanup_frees_bass(self): + """cleanup should call BASS_Free when bass was initialized.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + original_bass_free = getattr(player_module, "BASS_Free", None) + + try: + player_module._use_sound_lib = True + player_module._bass_initialized = True + + mock_bass_free = MagicMock() + player_module.BASS_Free = mock_bass_free + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + player.cleanup() + + mock_bass_free.assert_called_once() + assert player_module._bass_initialized is False + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init + if original_bass_free is not None: + player_module.BASS_Free = original_bass_free + elif hasattr(player_module, "BASS_Free"): + delattr(player_module, "BASS_Free") + + def test_cleanup_bass_free_error_suppressed(self): + """cleanup should suppress BASS_Free errors.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + original_bass_free = getattr(player_module, "BASS_Free", None) + + try: + player_module._use_sound_lib = True + player_module._bass_initialized = True + + mock_bass_free = MagicMock(side_effect=RuntimeError("bass error")) + player_module.BASS_Free = mock_bass_free + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + # Should not raise + player.cleanup() + mock_bass_free.assert_called_once() + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init + if original_bass_free is not None: + player_module.BASS_Free = original_bass_free + elif hasattr(player_module, "BASS_Free"): + delattr(player_module, "BASS_Free") + class TestSoundLibIntegration: """Test sound_lib integration (cross-platform).""" @@ -176,33 +586,37 @@ def test_sound_lib_play_creates_stream(self): """Playing with sound_lib should create a FileStream.""" import accessiclock.audio.player as player_module from accessiclock.audio.player import AudioPlayer - + # Create a mock stream module mock_stream_module = MagicMock() mock_file_stream = MagicMock() mock_stream_module.FileStream.return_value = mock_file_stream - + player = AudioPlayer.__new__(AudioPlayer) player._volume = 50 player._current_stream = None - + # Create temp file with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: temp_path = f.name - + try: # Patch stream in the module - with patch.object(player_module, "stream", mock_stream_module, create=True): + with patch.object( + player_module, "stream", mock_stream_module, create=True + ): player._play_with_sound_lib(Path(temp_path)) - + # Verify FileStream was created with correct path mock_stream_module.FileStream.assert_called_once() call_args = mock_stream_module.FileStream.call_args # Check if temp_path was passed as positional or keyword arg - passed_path = call_args.kwargs.get("file") or (call_args.args[0] if call_args.args else None) + passed_path = call_args.kwargs.get("file") or ( + call_args.args[0] if call_args.args else None + ) assert passed_path is not None assert str(Path(passed_path)) == str(Path(temp_path)) - + # Verify play was called mock_file_stream.play.assert_called_once() finally: @@ -212,22 +626,24 @@ def test_sound_lib_sets_volume_on_stream(self): """Playing should set volume on the stream.""" import accessiclock.audio.player as player_module from accessiclock.audio.player import AudioPlayer - + mock_stream_module = MagicMock() mock_file_stream = MagicMock() mock_stream_module.FileStream.return_value = mock_file_stream - + player = AudioPlayer.__new__(AudioPlayer) player._volume = 75 # 75% player._current_stream = None - + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: temp_path = f.name - + try: - with patch.object(player_module, "stream", mock_stream_module, create=True): + with patch.object( + player_module, "stream", mock_stream_module, create=True + ): player._play_with_sound_lib(Path(temp_path)) - + # Verify volume was set to 0.75 assert mock_file_stream.volume == 0.75 finally: @@ -237,26 +653,27 @@ def test_sound_lib_stops_previous_stream(self): """Playing a new sound should stop the previous stream.""" import accessiclock.audio.player as player_module from accessiclock.audio.player import AudioPlayer - + mock_stream_module = MagicMock() - mock_old_stream = MagicMock() mock_new_stream = MagicMock() mock_stream_module.FileStream.return_value = mock_new_stream - + player = AudioPlayer.__new__(AudioPlayer) player._volume = 50 - player._current_stream = mock_old_stream - + player._current_stream = MagicMock() # existing stream + # Mock stop method player.stop = MagicMock() - + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: temp_path = f.name - + try: - with patch.object(player_module, "stream", mock_stream_module, create=True): + with patch.object( + player_module, "stream", mock_stream_module, create=True + ): player._play_with_sound_lib(Path(temp_path)) - + # stop() should have been called (which stops/frees old stream) player.stop.assert_called_once() finally: @@ -266,24 +683,24 @@ def test_sound_lib_is_playing_checks_stream(self): """is_playing should check the stream's is_playing property.""" import accessiclock.audio.player as player_module from accessiclock.audio.player import AudioPlayer - + original_use_sound_lib = player_module._use_sound_lib try: player_module._use_sound_lib = True - + player = AudioPlayer.__new__(AudioPlayer) player._volume = 50 - + # No stream - not playing player._current_stream = None assert player.is_playing() is False - + # Stream playing mock_stream = MagicMock() mock_stream.is_playing = True player._current_stream = mock_stream assert player.is_playing() is True - + # Stream stopped mock_stream.is_playing = False assert player.is_playing() is False @@ -294,21 +711,67 @@ def test_sound_lib_stop_frees_stream(self): """stop should stop and free the current stream.""" import accessiclock.audio.player as player_module from accessiclock.audio.player import AudioPlayer - + original_use_sound_lib = player_module._use_sound_lib try: player_module._use_sound_lib = True - + player = AudioPlayer.__new__(AudioPlayer) player._volume = 50 - + mock_stream = MagicMock() player._current_stream = mock_stream - + player.stop() - + mock_stream.stop.assert_called_once() mock_stream.free.assert_called_once() assert player._current_stream is None finally: player_module._use_sound_lib = original_use_sound_lib + + def test_init_bass_via_sound_lib(self): + """AudioPlayer __init__ should initialize BASS output.""" + import accessiclock.audio.player as player_module + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + + try: + player_module._use_sound_lib = True + player_module._bass_initialized = False + + mock_output_module = MagicMock() + + with patch.dict("sys.modules", {"sound_lib.output": mock_output_module}): + from accessiclock.audio.player import AudioPlayer + + AudioPlayer() + assert player_module._bass_initialized is True + mock_output_module.Output.assert_called_once() + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init + + def test_init_bass_failure_raises(self): + """AudioPlayer __init__ should raise if BASS init fails.""" + import accessiclock.audio.player as player_module + + original_use = player_module._use_sound_lib + original_init = player_module._bass_initialized + + try: + player_module._use_sound_lib = True + player_module._bass_initialized = False + + mock_output_module = MagicMock() + mock_output_module.Output.side_effect = RuntimeError("BASS init failed") + + with patch.dict("sys.modules", {"sound_lib.output": mock_output_module}): + from accessiclock.audio.player import AudioPlayer + + with pytest.raises(RuntimeError, match="BASS init failed"): + AudioPlayer() + finally: + player_module._use_sound_lib = original_use + player_module._bass_initialized = original_init diff --git a/tests/test_tts_engine.py b/tests/test_tts_engine.py index 99547c0..8122bc0 100644 --- a/tests/test_tts_engine.py +++ b/tests/test_tts_engine.py @@ -1,12 +1,7 @@ -"""Tests for accessiclock.audio.tts_engine module. +"""Tests for accessiclock.audio.tts_engine module.""" -TDD: These tests are written before the implementation. -""" - -from datetime import time -from unittest.mock import MagicMock - -import pytest +from datetime import date, time +from unittest.mock import MagicMock, patch class TestTTSEngine: @@ -15,27 +10,115 @@ class TestTTSEngine: def test_init_default_engine(self): """Should initialize with SAPI5 as default engine on Windows.""" from accessiclock.audio.tts_engine import TTSEngine - + engine = TTSEngine() assert engine.engine_type in ("sapi5", "dummy") def test_init_with_custom_rate(self): """Should accept custom speech rate.""" from accessiclock.audio.tts_engine import TTSEngine - + engine = TTSEngine(rate=200) assert engine.rate == 200 def test_rate_clamped_to_valid_range(self): """Rate should be clamped to valid range.""" from accessiclock.audio.tts_engine import TTSEngine - + engine = TTSEngine(rate=500) # Too high assert engine.rate <= 300 - + engine2 = TTSEngine(rate=10) # Too low assert engine2.rate >= 50 + def test_init_force_dummy(self): + """force_dummy should override pyttsx3 availability.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + assert engine.engine_type == "dummy" + assert engine._engine is None + + def test_init_pyttsx3_available(self): + """Should use sapi5 engine when pyttsx3 is available.""" + mock_engine = MagicMock() + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + assert engine.engine_type == "sapi5" + assert engine._engine is mock_engine + mock_pyttsx3.init.assert_called_once() + mock_engine.setProperty.assert_called_once_with("rate", 150) + + def test_init_pyttsx3_init_failure(self): + """Should fall back to dummy if pyttsx3.init fails.""" + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.side_effect = RuntimeError("init failed") + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + assert engine.engine_type == "dummy" + + +class TestRateProperty: + """Test rate getter/setter.""" + + def test_rate_getter(self): + """rate property should return current rate.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True, rate=175) + assert engine.rate == 175 + + def test_rate_setter_dummy(self): + """rate setter on dummy should update value without engine.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.rate = 250 + assert engine.rate == 250 + + def test_rate_setter_clamps(self): + """rate setter should clamp values.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.rate = 999 + assert engine.rate == 300 + engine.rate = 1 + assert engine.rate == 50 + + def test_rate_setter_with_engine(self): + """rate setter should update pyttsx3 engine.""" + mock_engine = MagicMock() + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + mock_engine.reset_mock() + + engine.rate = 200 + assert engine.rate == 200 + mock_engine.setProperty.assert_called_once_with("rate", 200) + class TestTimeFormatting: """Test time-to-speech formatting.""" @@ -43,126 +126,391 @@ class TestTimeFormatting: def test_format_time_12h_simple(self): """Should format time in simple 12-hour format.""" from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine() - + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(15, 30), style="simple") assert "3" in result assert "30" in result - assert "PM" in result.upper() or "P.M." in result.upper() + assert "PM" in result + + def test_format_time_simple_on_hour(self): + """Simple style on the hour should omit minutes.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(15, 0), style="simple") + assert result == "3 PM" + + def test_format_time_simple_midnight(self): + """Midnight should be 12 AM.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(0, 0), style="simple") + assert "12" in result + assert "AM" in result + + def test_format_time_simple_noon(self): + """Noon should be 12 PM.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(12, 0), style="simple") + assert "12" in result + assert "PM" in result def test_format_time_natural(self): """Should format time in natural speech style.""" from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine() - + + engine = TTSEngine(force_dummy=True) + # Quarter past result = engine.format_time(time(14, 15), style="natural") - assert "quarter" in result.lower() or "15" in result - + assert "quarter past" in result.lower() + # Half past result = engine.format_time(time(14, 30), style="natural") - assert "half" in result.lower() or "30" in result - + assert "half past" in result.lower() + # On the hour result = engine.format_time(time(15, 0), style="natural") - assert "o'clock" in result.lower() or "00" in result or "3" in result + assert "o'clock" in result.lower() + + def test_format_time_natural_quarter_to(self): + """Natural style at :45 should say 'quarter to'.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(14, 45), style="natural") + assert "quarter to" in result.lower() + + def test_format_time_natural_irregular_minute(self): + """Natural style with irregular minutes should show time normally.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(14, 22), style="natural") + assert "2:22" in result + assert "PM" in result def test_format_time_precise(self): """Should format time with full precision.""" from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine() - + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(9, 5), style="precise") - assert "9" in result - assert "05" in result or "5" in result - assert "AM" in result.upper() or "A.M." in result.upper() + assert "The time is" in result + assert "9:05" in result + assert "AM" in result def test_format_time_with_date(self): """Should optionally include date.""" - from datetime import datetime - from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine() - + + engine = TTSEngine(force_dummy=True) + result = engine.format_time( time(12, 0), include_date=True, - date=datetime(2025, 1, 24).date() + date=date(2025, 1, 24), ) assert "January" in result or "24" in result or "Friday" in result + def test_format_time_without_date_flag(self): + """include_date=False should not include date.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(12, 0), include_date=False) + assert "January" not in result + assert "Monday" not in result + + def test_format_time_include_date_no_date_provided(self): + """include_date=True but no date should not include date.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(12, 0), include_date=True, date=None) + # Should still work, just no date prefix + assert "12" in result + class TestSpeech: """Test speech synthesis.""" - def test_speak_uses_engine(self): - """speak() should use the TTS engine when pyttsx3 available.""" - from accessiclock.audio.tts_engine import _PYTTSX3_AVAILABLE, TTSEngine - - if not _PYTTSX3_AVAILABLE: - # Can't test real engine without pyttsx3 - pytest.skip("pyttsx3 not available") - - # Test with real engine (mocked) - engine = TTSEngine() - if engine._engine: - engine._engine.say = MagicMock() - engine._engine.runAndWait = MagicMock() - + def test_speak_dummy_no_crash(self): + """speak() with dummy engine should not crash.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.speak("Hello world") # Should not raise + + def test_speak_with_engine(self): + """speak() should use pyttsx3 engine.""" + mock_engine = MagicMock() + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + engine.speak("Hello world") + + mock_engine.say.assert_called_once_with("Hello world") + mock_engine.runAndWait.assert_called_once() + + def test_speak_engine_error_handled(self): + """speak() should handle engine errors gracefully.""" + mock_engine = MagicMock() + mock_engine.say.side_effect = RuntimeError("speech error") + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + # Should not raise engine.speak("Hello world") - - engine._engine.say.assert_called() - engine._engine.runAndWait.assert_called() def test_speak_time_combines_format_and_speak(self): """speak_time() should format and speak the time.""" from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine(force_dummy=True) # Use dummy to avoid pyttsx3 - engine.speak = MagicMock() # Mock the speak method - + + engine = TTSEngine(force_dummy=True) + engine.speak = MagicMock() + engine.speak_time(time(15, 0)) - + engine.speak.assert_called_once() call_arg = engine.speak.call_args[0][0] - assert "3" in call_arg or "15" in call_arg + assert "3" in call_arg + + def test_speak_time_with_style(self): + """speak_time() should pass style to format_time.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.speak = MagicMock() + + engine.speak_time(time(14, 30), style="natural") + + call_arg = engine.speak.call_args[0][0] + assert "half past" in call_arg.lower() + + def test_speak_time_with_date(self): + """speak_time() should support include_date.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.speak = MagicMock() + + engine.speak_time( + time(12, 0), + include_date=True, + current_date=date(2025, 6, 15), + ) + + call_arg = engine.speak.call_args[0][0] + assert "June" in call_arg or "15" in call_arg class TestVoiceSelection: """Test voice selection and listing.""" - def test_list_voices(self): - """Should list available voices.""" + def test_list_voices_dummy(self): + """Dummy engine should return empty list.""" from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine() + + engine = TTSEngine(force_dummy=True) voices = engine.list_voices() - - assert isinstance(voices, list) - # May be empty on systems without TTS + assert voices == [] + + def test_list_voices_with_engine(self): + """list_voices should return voice info from pyttsx3.""" + mock_engine = MagicMock() + mock_voice1 = MagicMock() + mock_voice1.id = "voice1_id" + mock_voice1.name = "Voice One" + mock_voice2 = MagicMock() + mock_voice2.id = "voice2_id" + mock_voice2.name = "Voice Two" + mock_engine.getProperty.return_value = [mock_voice1, mock_voice2] + + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + voices = engine.list_voices() + + assert len(voices) == 2 + assert voices[0] == {"id": "voice1_id", "name": "Voice One"} + assert voices[1] == {"id": "voice2_id", "name": "Voice Two"} + mock_engine.getProperty.assert_called_with("voices") + + def test_list_voices_error_returns_empty(self): + """list_voices should return empty list on error.""" + mock_engine = MagicMock() + mock_engine.getProperty.side_effect = RuntimeError("voices error") + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + voices = engine.list_voices() + assert voices == [] + + def test_set_voice_dummy_returns_false(self): + """set_voice on dummy engine should return False.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.set_voice("Some Voice") + assert result is False def test_set_voice_by_name(self): - """Should set voice by name when pyttsx3 available.""" - from accessiclock.audio.tts_engine import _PYTTSX3_AVAILABLE, TTSEngine - - if not _PYTTSX3_AVAILABLE: - # Test that dummy engine returns False - tts = TTSEngine(force_dummy=True) - result = tts.set_voice("Microsoft David") - assert result is False - return - - # Test with real engine - tts = TTSEngine() - voices = tts.list_voices() - if voices: - # Try to set the first available voice - result = tts.set_voice(voices[0]["name"]) + """set_voice should match by name.""" + mock_engine = MagicMock() + mock_voice = MagicMock() + mock_voice.id = "voice_id_1" + mock_voice.name = "Microsoft David" + mock_engine.getProperty.return_value = [mock_voice] + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + result = engine.set_voice("Microsoft David") + assert result is True + mock_engine.setProperty.assert_called_with("voice", "voice_id_1") + + def test_set_voice_by_id(self): + """set_voice should match by id.""" + mock_engine = MagicMock() + mock_voice = MagicMock() + mock_voice.id = "HKEY_VOICE_1" + mock_voice.name = "Microsoft David" + mock_engine.getProperty.return_value = [mock_voice] + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + result = engine.set_voice("HKEY_VOICE_1") assert result is True + mock_engine.setProperty.assert_called_with("voice", "HKEY_VOICE_1") + + def test_set_voice_not_found(self): + """set_voice should return False if voice not found.""" + mock_engine = MagicMock() + mock_voice = MagicMock() + mock_voice.id = "voice_id_1" + mock_voice.name = "Microsoft David" + mock_engine.getProperty.return_value = [mock_voice] + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + result = engine.set_voice("Nonexistent Voice") + assert result is False + + def test_set_voice_error_returns_false(self): + """set_voice should return False on error.""" + mock_engine = MagicMock() + mock_engine.getProperty.side_effect = RuntimeError("voice error") + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + result = engine.set_voice("Some Voice") + assert result is False + + +class TestCleanup: + """Test cleanup method.""" + + def test_cleanup_dummy_no_crash(self): + """Cleanup on dummy engine should not crash.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.cleanup() # Should not raise + + def test_cleanup_with_engine(self): + """Cleanup should stop pyttsx3 engine.""" + mock_engine = MagicMock() + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + engine.cleanup() + mock_engine.stop.assert_called_once() + + def test_cleanup_engine_error_suppressed(self): + """Cleanup should suppress engine stop errors.""" + mock_engine = MagicMock() + mock_engine.stop.side_effect = RuntimeError("stop error") + mock_pyttsx3 = MagicMock() + mock_pyttsx3.init.return_value = mock_engine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3", mock_pyttsx3, create=True), + ): + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine() + # Should not raise + engine.cleanup() class TestDummyEngine: @@ -171,14 +519,28 @@ class TestDummyEngine: def test_dummy_engine_does_not_crash(self): """Dummy engine should not crash when TTS unavailable.""" from accessiclock.audio.tts_engine import TTSEngine - + # Force dummy mode engine = TTSEngine(force_dummy=True) - + # These should not raise engine.speak("Test") engine.speak_time(time(12, 0)) voices = engine.list_voices() - + assert engine.engine_type == "dummy" assert voices == [] + + def test_dummy_set_voice_returns_false(self): + """Dummy engine set_voice should always return False.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + assert engine.set_voice("any") is False + + def test_dummy_list_voices_empty(self): + """Dummy engine list_voices should return empty list.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + assert engine.list_voices() == []