diff --git a/tests/test_audio_player.py b/tests/test_audio_player.py index c7a6a90..e4d026f 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,10 +38,79 @@ 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): + """When sound_lib is available and BASS not initialized, should init BASS.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + orig_use = player_module._use_sound_lib + orig_init = player_module._bass_initialized + try: + player_module._use_sound_lib = True + player_module._bass_initialized = False + + mock_output = MagicMock() + mock_sound_lib = MagicMock() + mock_sound_lib.output = mock_output + with patch.dict( + "sys.modules", + {"sound_lib": mock_sound_lib, "sound_lib.output": mock_output}, + ): + AudioPlayer(volume_percent=50) + mock_output.Output.assert_called_once() + assert player_module._bass_initialized is True + finally: + player_module._use_sound_lib = orig_use + player_module._bass_initialized = orig_init + + def test_init_bass_already_initialized_skips(self): + """When BASS already initialized, should not re-init.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + orig_use = player_module._use_sound_lib + orig_init = player_module._bass_initialized + try: + player_module._use_sound_lib = True + player_module._bass_initialized = True + + player = AudioPlayer(volume_percent=50) + assert player.get_volume() == 50 + finally: + player_module._use_sound_lib = orig_use + player_module._bass_initialized = orig_init + + def test_init_bass_init_failure_raises(self): + """When BASS init fails, should raise the exception.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + orig_use = player_module._use_sound_lib + orig_init = player_module._bass_initialized + try: + player_module._use_sound_lib = True + player_module._bass_initialized = False + + mock_output = MagicMock() + mock_output.Output.side_effect = RuntimeError("BASS init failed") + mock_sound_lib = MagicMock() + mock_sound_lib.output = mock_output + with ( + patch.dict( + "sys.modules", + {"sound_lib": mock_sound_lib, "sound_lib.output": mock_output}, + ), + pytest.raises(RuntimeError, match="BASS init failed"), + ): + AudioPlayer(volume_percent=50) + finally: + player_module._use_sound_lib = orig_use + player_module._bass_initialized = orig_init + class TestVolumeControl: """Test volume control methods.""" @@ -51,6 +120,7 @@ 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 +144,27 @@ 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_active_stream(self): + """set_volume should update stream volume when playing.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + orig_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 = orig_use + class TestPlaySound: """Test sound playback methods.""" @@ -82,39 +173,141 @@ 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 + + orig_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 = orig_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 + + orig_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 = orig_use + + def test_play_with_sound_lib_error_raises(self): + """_play_with_sound_lib should re-raise on error.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + mock_stream_mod = MagicMock() + mock_stream_mod.FileStream.side_effect = RuntimeError("stream error") + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + with ( + patch.object(player_module, "stream", mock_stream_mod, create=True), + pytest.raises(RuntimeError, match="stream error"), + ): + player._play_with_sound_lib(Path("/tmp/test.wav")) + + def test_play_with_fallback_playsound3(self): + """_play_with_fallback should launch a thread with playsound3.""" + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + mock_module = MagicMock() + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + temp_path = f.name + + try: + with ( + patch.dict("sys.modules", {"playsound3": mock_module}), + patch("threading.Thread") as mock_thread_cls, + ): + mock_thread = MagicMock() + mock_thread_cls.return_value = mock_thread + player._play_with_fallback(Path(temp_path)) + mock_thread_cls.assert_called_once() + mock_thread.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 when playsound3 not installed.""" + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + with ( + patch.dict("sys.modules", {"playsound3": None}), + pytest.raises(ImportError), + ): + player._play_with_fallback(Path("/tmp/test.wav")) + + def test_play_with_fallback_general_exception(self): + """_play_with_fallback should re-raise general exceptions.""" + from accessiclock.audio.player import AudioPlayer + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + player._current_stream = None + + mock_module = MagicMock() + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + temp_path = f.name + + try: + with ( + patch.dict("sys.modules", {"playsound3": mock_module}), + patch("threading.Thread", side_effect=RuntimeError("play failed")), + pytest.raises(RuntimeError, match="play failed"), + ): + player._play_with_fallback(Path(temp_path)) + finally: + Path(temp_path).unlink(missing_ok=True) class TestIsPlaying: @@ -124,10 +317,55 @@ 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 if stream raises.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + orig_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 = orig_use + + +class TestStop: + """Test stop method.""" + + def test_stop_exception_clears_stream(self): + """stop() should set stream to None even if stop/free raise.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + orig_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 = orig_use + class TestCleanup: """Test cleanup method.""" @@ -136,38 +374,118 @@ 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() 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 + from accessiclock.audio.player import AudioPlayer + 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_module._bass_initialized = False + + player = AudioPlayer.__new__(AudioPlayer) + player._volume = 50 + mock_current = MagicMock() + player._current_stream = mock_current + 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_handled(self): + """cleanup should handle errors when stopping stream.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + orig_use = player_module._use_sound_lib + orig_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_current = MagicMock() + mock_current.stop.side_effect = RuntimeError("cleanup error") + player._current_stream = mock_current + + # Should not raise + player.cleanup() + finally: + player_module._use_sound_lib = orig_use + player_module._bass_initialized = orig_init + + def test_cleanup_frees_bass(self): + """cleanup should call BASS_Free when sound_lib initialized.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + orig_use = player_module._use_sound_lib + orig_init = player_module._bass_initialized + orig_bass_free = getattr(player_module, "BASS_Free", None) + + mock_bass_free = MagicMock() + + try: + player_module._use_sound_lib = True + player_module._bass_initialized = True + 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 = orig_use + player_module._bass_initialized = orig_init + if orig_bass_free is not None: + player_module.BASS_Free = orig_bass_free + + def test_cleanup_bass_free_error_handled(self): + """cleanup should handle errors from BASS_Free.""" + import accessiclock.audio.player as player_module + from accessiclock.audio.player import AudioPlayer + + orig_use = player_module._use_sound_lib + orig_init = player_module._bass_initialized + orig_bass_free = getattr(player_module, "BASS_Free", None) + + mock_bass_free = MagicMock(side_effect=RuntimeError("bass error")) + + try: + player_module._use_sound_lib = True + player_module._bass_initialized = True + 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 = orig_use + player_module._bass_initialized = orig_init + if orig_bass_free is not None: + player_module.BASS_Free = orig_bass_free + class TestSoundLibIntegration: """Test sound_lib integration (cross-platform).""" @@ -176,34 +494,23 @@ 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): 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) - 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: Path(temp_path).unlink(missing_ok=True) @@ -212,23 +519,22 @@ 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._volume = 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): player._play_with_sound_lib(Path(temp_path)) - - # Verify volume was set to 0.75 + assert mock_file_stream.volume == 0.75 finally: Path(temp_path).unlink(missing_ok=True) @@ -237,27 +543,23 @@ 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 - - # Mock stop method + player._current_stream = MagicMock() 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): player._play_with_sound_lib(Path(temp_path)) - - # stop() should have been called (which stops/frees old stream) + player.stop.assert_called_once() finally: Path(temp_path).unlink(missing_ok=True) @@ -266,25 +568,22 @@ 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 finally: @@ -294,19 +593,19 @@ 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 diff --git a/tests/test_tts_engine.py b/tests/test_tts_engine.py index 99547c0..459db5c 100644 --- a/tests/test_tts_engine.py +++ b/tests/test_tts_engine.py @@ -1,12 +1,20 @@ -"""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 date, time +from unittest.mock import MagicMock, patch -from datetime import time -from unittest.mock import MagicMock -import pytest +# Helper to create a TTSEngine with mocked pyttsx3 +def _make_tts_with_engine(mock_engine, rate=150): + """Create a TTSEngine with a mocked pyttsx3 backend.""" + from accessiclock.audio.tts_engine import TTSEngine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3") as mock_pyttsx3, + ): + mock_pyttsx3.init.return_value = mock_engine + return TTSEngine(rate=rate) class TestTTSEngine: @@ -15,27 +23,70 @@ 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 + + engine = TTSEngine(rate=500) assert engine.rate <= 300 - - engine2 = TTSEngine(rate=10) # Too low + + engine2 = TTSEngine(rate=10) assert engine2.rate >= 50 + def test_init_pyttsx3_success(self): + """Should initialize sapi5 engine when pyttsx3 is available.""" + mock_engine = MagicMock() + tts = _make_tts_with_engine(mock_engine) + assert tts.engine_type == "sapi5" + + def test_init_pyttsx3_failure_falls_back_to_dummy(self): + """Should fall back to dummy if pyttsx3.init raises.""" + from accessiclock.audio.tts_engine import TTSEngine + + with ( + patch("accessiclock.audio.tts_engine._PYTTSX3_AVAILABLE", True), + patch("accessiclock.audio.tts_engine.pyttsx3") as mock_pyttsx3, + ): + mock_pyttsx3.init.side_effect = RuntimeError("no audio") + engine = TTSEngine(rate=150) + assert engine.engine_type == "dummy" + + def test_force_dummy(self): + """force_dummy should use dummy engine regardless.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + assert engine.engine_type == "dummy" + + def test_rate_setter_updates_engine(self): + """Setting rate should update both internal and engine property.""" + mock_engine = MagicMock() + tts = _make_tts_with_engine(mock_engine) + mock_engine.reset_mock() + + tts.rate = 200 + assert tts.rate == 200 + mock_engine.setProperty.assert_called_with("rate", 200) + + def test_rate_setter_dummy_no_crash(self): + """Setting rate on dummy engine should not crash.""" + from accessiclock.audio.tts_engine import TTSEngine + + tts = TTSEngine(force_dummy=True) + tts.rate = 250 + assert tts.rate == 250 + class TestTimeFormatting: """Test time-to-speech formatting.""" @@ -43,126 +94,259 @@ 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.upper() + + def test_format_time_simple_on_the_hour(self): + """Simple format 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 "3" in result + assert "PM" in result.upper() def test_format_time_natural(self): """Should format time in natural speech style.""" from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine() - - # Quarter past + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(14, 15), style="natural") - assert "quarter" in result.lower() or "15" in result - - # Half past + assert "quarter" in result.lower() + result = engine.format_time(time(14, 30), style="natural") - assert "half" in result.lower() or "30" in result - - # On the hour + assert "half" in result.lower() + 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 format for :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_other_minutes(self): + """Natural format for non-special minutes should show time.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + result = engine.format_time(time(14, 22), style="natural") + assert "22" in result + assert "PM" in result.upper() 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 "05" in result + assert "AM" in result.upper() + assert result.startswith("The time is") 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_midnight_hour(self): + """Midnight (hour 0) should display as 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.upper() + + def test_format_time_noon(self): + """Noon (hour 12) should display as 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.upper() + class TestSpeech: """Test speech synthesis.""" + def test_speak_dummy_does_not_crash(self): + """speak() with dummy engine should not raise.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.speak("Hello world") + 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() - - engine.speak("Hello world") - - engine._engine.say.assert_called() - engine._engine.runAndWait.assert_called() + """speak() should call engine.say and runAndWait.""" + mock_engine = MagicMock() + tts = _make_tts_with_engine(mock_engine) + tts.speak("Hello") + mock_engine.say.assert_called_with("Hello") + mock_engine.runAndWait.assert_called_once() + + def test_speak_error_handled(self): + """speak() should not raise on engine error.""" + mock_engine = MagicMock() + mock_engine.say.side_effect = RuntimeError("speak error") + tts = _make_tts_with_engine(mock_engine) + # Should not raise + tts.speak("Hello") 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 + def test_speak_time_with_style_and_date(self): + """speak_time with all options should work.""" + from accessiclock.audio.tts_engine import TTSEngine + + engine = TTSEngine(force_dummy=True) + engine.speak = MagicMock() + + engine.speak_time( + time(9, 30), + style="natural", + include_date=True, + current_date=date(2025, 6, 15), + ) + + engine.speak.assert_called_once() + call_arg = engine.speak.call_args[0][0] + assert "half past" in call_arg.lower() or "30" in call_arg + class TestVoiceSelection: """Test voice selection and listing.""" - def test_list_voices(self): - """Should list available voices.""" + def _make_mock_voice(self, voice_id="voice1", name="Test Voice"): + mock_voice = MagicMock() + mock_voice.id = voice_id + mock_voice.name = name + return mock_voice + + def test_list_voices_dummy_returns_empty(self): + """Dummy engine should return empty voice list.""" from accessiclock.audio.tts_engine import TTSEngine - - engine = TTSEngine() - voices = engine.list_voices() - - assert isinstance(voices, list) - # May be empty on systems without TTS - 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() + engine = TTSEngine(force_dummy=True) + assert engine.list_voices() == [] + + def test_list_voices_with_engine(self): + """Should list available voices from engine.""" + mock_engine = MagicMock() + mock_voice = self._make_mock_voice() + mock_engine.getProperty.return_value = [mock_voice] + + tts = _make_tts_with_engine(mock_engine) voices = tts.list_voices() - if voices: - # Try to set the first available voice - result = tts.set_voice(voices[0]["name"]) - assert result is True + assert len(voices) == 1 + assert voices[0]["id"] == "voice1" + assert voices[0]["name"] == "Test Voice" + + def test_list_voices_exception_returns_empty(self): + """list_voices should return [] on exception.""" + mock_engine = MagicMock() + mock_engine.getProperty.side_effect = RuntimeError("voice error") + + tts = _make_tts_with_engine(mock_engine) + assert tts.list_voices() == [] + + def test_set_voice_dummy_returns_false(self): + """Dummy engine should return False for set_voice.""" + from accessiclock.audio.tts_engine import TTSEngine + + tts = TTSEngine(force_dummy=True) + assert tts.set_voice("Microsoft David") is False + + def test_set_voice_by_name(self): + """Should set voice when matching by name.""" + mock_engine = MagicMock() + mock_voice = self._make_mock_voice() + mock_engine.getProperty.return_value = [mock_voice] + + tts = _make_tts_with_engine(mock_engine) + assert tts.set_voice("Test Voice") is True + mock_engine.setProperty.assert_called_with("voice", "voice1") + + def test_set_voice_by_id(self): + """Should set voice when matching by ID.""" + mock_engine = MagicMock() + mock_voice = self._make_mock_voice() + mock_engine.getProperty.return_value = [mock_voice] + + tts = _make_tts_with_engine(mock_engine) + assert tts.set_voice("voice1") is True + mock_engine.setProperty.assert_called_with("voice", "voice1") + + def test_set_voice_not_found(self): + """Should return False when voice not found.""" + mock_engine = MagicMock() + mock_voice = self._make_mock_voice() + mock_engine.getProperty.return_value = [mock_voice] + + tts = _make_tts_with_engine(mock_engine) + assert tts.set_voice("Nonexistent Voice") is False + + def test_set_voice_exception(self): + """Should return False on exception during voice setting.""" + mock_engine = MagicMock() + mock_engine.getProperty.side_effect = RuntimeError("voice error") + + tts = _make_tts_with_engine(mock_engine) + assert tts.set_voice("Any Voice") is False + + +class TestCleanup: + """Test TTS engine cleanup.""" + + 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() + + def test_cleanup_stops_engine(self): + """Cleanup should call engine.stop().""" + mock_engine = MagicMock() + tts = _make_tts_with_engine(mock_engine) + tts.cleanup() + mock_engine.stop.assert_called_once() + + def test_cleanup_exception_suppressed(self): + """Cleanup should suppress exceptions from engine.stop().""" + mock_engine = MagicMock() + mock_engine.stop.side_effect = RuntimeError("cleanup error") + tts = _make_tts_with_engine(mock_engine) + # Should not raise + tts.cleanup() class TestDummyEngine: @@ -171,14 +355,12 @@ 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 == []