"""Speech discovery is read-only; all dispatcher connections here are mocked.""" import json from pathlib import Path import sys import tempfile from types import SimpleNamespace import unittest from unittest.mock import Mock, patch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from talking_clock.config import Config, Settings from talking_clock.speech import connect, discover, matching_voices, system_language class SpeechTests(unittest.TestCase): def test_locale_precedence_encoding_and_c_fallback(self): self.assertEqual(system_language({"LC_ALL": "de_DE.UTF-8", "LANG": "en_US.UTF-8"}), "de-de") self.assertEqual(system_language({"LC_ALL": "C.UTF-8", "LANGUAGE": "es_MX:en"}), "es-mx") self.assertEqual(system_language({"LC_MESSAGES": "fr_CA.UTF-8@modifier", "LANG": "en_US.UTF-8"}), "fr-ca") self.assertEqual(system_language({"LANG": "pt_BR.UTF-8"}), "pt-br") self.assertEqual(system_language({"LC_ALL": "POSIX", "LANG": "C"}), "en") def test_voice_filter_prefers_region_and_falls_back_to_language(self): voices = (("British", "en-gb", ""), ("American", "en_US", ""), ("French", "fr", ""), ("Unknown", None, None), ("British", "en-gb", "other"), ("English", "EN", "")) self.assertEqual(matching_voices(voices, "en-us"), ("American",)) self.assertEqual(matching_voices(voices, "en-au"), ("American", "British", "English")) self.assertEqual(matching_voices(voices, "en"), ("English",)) self.assertEqual(matching_voices(voices, "fr-fr"), ("French",)) self.assertEqual(matching_voices(voices, "es"), ()) def test_discovery_queries_selected_module_and_closes_without_speaking(self): client = Mock() client.list_output_modules.return_value = ("rhvoice", "espeak-ng") client.list_synthesis_voices.return_value = (("American", "en-us", ""), ("French", "fr", "")) with patch("talking_clock.speech.connect", return_value=client), patch( "talking_clock.speech.system_language", return_value="en-us"): catalog = discover("espeak-ng") self.assertEqual(catalog.modules, ("espeak-ng", "rhvoice")) self.assertEqual(catalog.voices, ("American",)) client.set_output_module.assert_called_once_with("espeak-ng") client.close.assert_called_once() client.speak.assert_not_called() def test_system_default_module_is_queried_without_overriding_it(self): client = Mock() client.list_output_modules.return_value = ("espeak-ng",) client.get_output_module.return_value = "espeak-ng" client.list_synthesis_voices.return_value = () with patch("talking_clock.speech.connect", return_value=client): self.assertEqual(discover().module, "espeak-ng") client.set_output_module.assert_not_called() client.speak.assert_not_called() def test_connection_does_not_autostart_dispatcher(self): module = SimpleNamespace(SSIPClient=Mock()) with patch.dict(sys.modules, {"speechd": module}): connect() module.SSIPClient.assert_called_once_with("talking-clock", component="settings", autospawn=False) def test_unavailable_dispatcher_and_module_are_reported(self): with patch("talking_clock.speech.connect", side_effect=OSError("offline")): with self.assertRaisesRegex(RuntimeError, "offline"): discover() client = Mock() client.list_output_modules.return_value = () with patch("talking_clock.speech.connect", return_value=client): with self.assertRaisesRegex(RuntimeError, "unavailable"): discover("missing") client.close.assert_called_once() def test_numeric_limits_and_saved_intermediate_values(self): for key in ("rate", "pitch", "volume"): for value in (-100, -37, 0, 37, 100): settings = Settings(**{key: value}) self.assertIs(settings.validate(), settings) for value in (-101, 101, "10", True): with self.assertRaises(ValueError): Settings(**{key: value}).validate() def test_old_python_speech_preferences_migrate_to_dispatcher_defaults(self): with tempfile.TemporaryDirectory() as directory: config = Config(directory) config.path.write_text(json.dumps({"voice": "custom", "voice_command": "some old command", "rhvoice": "alan", "gama_path": "/old", "font_size": 100})) settings = config.load() self.assertEqual(settings.module, "") self.assertEqual(settings.voice, "") self.assertEqual(settings.font_size, 100) config.save(settings) data = json.loads(config.path.read_text()) self.assertFalse(set(data) & {"voice_command", "rhvoice", "gama_path"}) if __name__ == "__main__": unittest.main()