"""Opt-in desktop checks: TALKING_CLOCK_GUI_TESTS=1 python3 -m unittest discover -s tests.""" from dataclasses import replace from datetime import datetime import os from pathlib import Path import subprocess import sys import tempfile import time import unittest from unittest.mock import Mock, patch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) @unittest.skipUnless(os.environ.get("TALKING_CLOCK_GUI_TESTS") == "1", "requires an opted-in GTK display") class GuiTests(unittest.TestCase): @classmethod def setUpClass(cls): from talking_clock.gui import Gtk if not Gtk.init_check()[0]: raise RuntimeError("GUI tests require an accessible display") def setUp(self): from talking_clock.config import Config, Settings from talking_clock.runtime import Clock from talking_clock.gui import ClockWindow from talking_clock.speech import Catalog self.discovery = patch("talking_clock.gui.discover", return_value=Catalog(("espeak-ng", "rhvoice"), "espeak-ng", ("English (America)",), "en-us")) self.mock_discover = self.discovery.start() self.addCleanup(self.discovery.stop) self.directory = tempfile.TemporaryDirectory() self.clock = Clock(Config(self.directory.name), Settings(chime=False, speak=False), Mock()) self.clock.audio.weather.detect_location = Mock(return_value=None) self.window = ClockWindow(self.clock) def tearDown(self): self.window.destroy() self.directory.cleanup() def wait_until(self, predicate): from talking_clock.gui import GLib loop = GLib.MainLoop() deadline = time.monotonic() + 2 def check(): if predicate() or time.monotonic() >= deadline: loop.quit() return False return True GLib.timeout_add(10, check) loop.run() self.assertTrue(predicate()) def test_initial_focus_tab_and_shift_tab(self): from talking_clock.gui import Gtk self.assertIs(self.window.get_focus(), self.window.time) self.assertFalse(self.window.time.get_accessible().get_name()) self.assertFalse(self.window.time.get_accessible().get_description()) self.window.child_focus(Gtk.DirectionType.TAB_FORWARD) self.assertIsInstance(self.window.get_focus(), Gtk.Button) self.assertEqual(self.window.get_focus().get_label(), "_Settings") self.window.time.grab_focus() self.window.child_focus(Gtk.DirectionType.TAB_BACKWARD) # Toplevel Tab traversal wraps after leaving its container. if self.window.get_focus() is None: self.window.child_focus(Gtk.DirectionType.TAB_BACKWARD) self.assertIsInstance(self.window.get_focus(), Gtk.Button) def test_display_updates_and_supports_24_hour_and_hidden_seconds(self): with patch("talking_clock.gui.datetime") as clock_time: clock_time.now.return_value = datetime(2026, 10, 23, 14, 5, 6).astimezone() self.window.update_time() self.assertEqual(self.window.time.get_text(), "2:05:06 PM") clock_time.now.return_value = datetime(2026, 10, 23, 14, 5, 7).astimezone() self.window.update_time() self.assertEqual(self.window.time.get_text(), "2:05:07 PM") self.clock.settings = replace(self.clock.settings, time_format="24", seconds=False) self.window.update_time() self.assertEqual(self.window.time.get_text(), "14:05") def test_space_speaks_only_time_even_if_scheduled_speech_is_disabled(self): from talking_clock.gui import Gdk self.clock.settings = replace(self.clock.settings, speak=False, chime=True, weather=True) original = replace(self.clock.settings) self.clock.audio.speak = Mock() self.clock.audio.chime = Mock() self.clock.audio.weather.report = Mock() event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) event.keyval = Gdk.KEY_space event.state = Gdk.ModifierType(0) with patch("talking_clock.gui.datetime") as clock_time: clock_time.now.return_value = datetime(2026, 10, 23, 14, 5, 6).astimezone() self.assertTrue(self.window.time.emit("key-press-event", event)) self.clock.audio.worker.join(timeout=2) self.assertFalse(self.clock.audio.worker.is_alive()) self.assertEqual(self.clock.audio.speak.call_args.args[0], "2 oh 5 P M") self.clock.audio.chime.assert_not_called() self.clock.audio.weather.report.assert_not_called() self.assertEqual(self.clock.settings, original) def test_modified_space_and_other_keys_do_not_trigger_speech(self): from talking_clock.gui import Gdk self.clock.audio.announce = Mock() event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) event.keyval = Gdk.KEY_space event.state = Gdk.ModifierType.CONTROL_MASK self.assertFalse(self.window.time_key_pressed(self.window.time, event)) event.keyval = Gdk.KEY_Tab event.state = Gdk.ModifierType(0) self.assertFalse(self.window.time_key_pressed(self.window.time, event)) self.clock.audio.announce.assert_not_called() def test_space_weather_is_independent_of_scheduled_weather(self): from talking_clock.gui import Gdk event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) event.keyval = Gdk.KEY_space event.state = Gdk.ModifierType(0) for scheduled in (False, True): for on_space in (False, True): with self.subTest(scheduled=scheduled, on_space=on_space): self.clock.settings = replace(self.clock.settings, weather=scheduled, weather_on_space=on_space) original = replace(self.clock.settings) self.clock.audio.speak = Mock() self.clock.audio.weather.report = Mock(return_value="77 degrees Fahrenheit and cloudy") self.assertTrue(self.window.time_key_pressed(self.window.time, event)) self.clock.audio.worker.join(timeout=2) self.assertFalse(self.clock.audio.worker.is_alive()) text = self.clock.audio.speak.call_args.args[0] self.assertEqual("77 degrees Fahrenheit and cloudy" in text, on_space) self.assertEqual(self.clock.audio.weather.report.call_count, int(on_space)) self.assertEqual(self.clock.settings, original) def test_settings_tabs_save_and_apply_immediately(self): from talking_clock.gui import SettingsDialog dialog = SettingsDialog(self.window, self.clock) try: self.assertEqual(dialog.notebook.get_n_pages(), 5) self.assertEqual(set(dialog.controls), set(vars(self.clock.settings))) dialog.controls["font_size"].set_value(100) dialog.controls["high_contrast"].set_active(True) dialog.controls["half_hour"].set_active(True) dialog.controls["time_format"].set_active_id("24") dialog.controls["weather_on_space"].set_active(True) dialog.save() self.assertEqual(self.clock.settings.font_size, 100) self.assertTrue(self.clock.settings.half_hour) self.assertTrue(self.clock.settings.high_contrast) self.assertTrue(self.clock.settings.weather_on_space) self.assertEqual(self.clock.config.load(), self.clock.settings) finally: dialog.destroy() def test_combo_accessible_names_and_label_relations(self): from talking_clock.gui import Atk, SettingsDialog names = {"soundpack": "Soundpack", "module": "Module", "voice": "Voice", "rate": "Rate", "pitch": "Pitch", "volume": "Volume", "time_format": "Time format", "temperature_unit": "Temperature unit"} dialog = SettingsDialog(self.window, self.clock) try: self.wait_until(lambda: dialog.controls["voice"].get_sensitive()) for key, name in names.items(): control = dialog.controls[key] targets = [control, control.get_child()] if control.get_has_entry() else [control] for target in targets: with self.subTest(control=key, widget=type(target).__name__): accessible = target.get_accessible() self.assertEqual(accessible.get_name(), name) relation = accessible.ref_relation_set().get_relation_by_type(Atk.RelationType.LABELLED_BY) self.assertIsNotNone(relation) self.assertEqual(relation.get_target()[0].get_name(), name) finally: dialog.destroy() def test_speech_test_uses_unsaved_controls_without_saving(self): from talking_clock.gui import SettingsDialog dialog = SettingsDialog(self.window, self.clock) try: self.wait_until(lambda: dialog.controls["voice"].get_sensitive()) original = replace(self.clock.settings) dialog.controls["module"].set_active_id("espeak-ng") self.wait_until(lambda: dialog.controls["voice"].get_sensitive()) dialog.controls["voice"].set_active_id("English (America)") for key, value in (("rate", "30"), ("pitch", "-20"), ("volume", "50")): dialog.controls[key].set_active_id(value) self.clock.audio.speak = Mock() self.clock.audio.chime = Mock() self.clock.audio.weather.report = Mock() dialog.test_speech_button.clicked() self.clock.audio.worker.join(timeout=2) self.assertFalse(self.clock.audio.worker.is_alive()) preview = self.clock.audio.speak.call_args.args[2] self.assertEqual((preview.module, preview.voice, preview.rate, preview.pitch, preview.volume), ("espeak-ng", "English (America)", 30, -20, 50)) self.assertTrue(preview.speak) self.clock.audio.chime.assert_not_called() self.clock.audio.weather.report.assert_not_called() self.assertEqual(self.clock.settings, original) self.assertFalse(self.clock.config.path.exists()) finally: dialog.destroy() def test_speech_controls_are_combos_and_reload_voices_for_selected_module(self): from talking_clock.gui import Gtk, SettingsDialog from talking_clock.speech import Catalog self.clock.settings = replace(self.clock.settings, rate=37) dialog = SettingsDialog(self.window, self.clock) try: self.wait_until(lambda: dialog.controls["voice"].get_sensitive()) for key in ("module", "voice", "rate", "pitch", "volume"): self.assertIsInstance(dialog.controls[key], Gtk.ComboBoxText) self.assertEqual(dialog.controls["rate"].get_active_id(), "37") self.mock_discover.return_value = Catalog(("espeak-ng", "rhvoice"), "rhvoice", ("Alan",), "en-us") dialog.controls["module"].set_active_id("rhvoice") self.wait_until(lambda: dialog.controls["voice"].get_sensitive()) self.mock_discover.assert_called_with("rhvoice") self.assertTrue(dialog.controls["voice"].set_active_id("Alan")) self.assertFalse(dialog.controls["voice"].set_active_id("English (America)")) dialog.controls["pitch"].set_active_id("-20") dialog.controls["volume"].set_active_id("50") dialog.save() self.assertEqual(self.clock.settings.module, "rhvoice") self.assertEqual(self.clock.settings.voice, "Alan") self.assertEqual(self.clock.settings.rate, 37) self.assertEqual(self.clock.settings.pitch, -20) self.assertEqual(self.clock.settings.volume, 50) finally: dialog.destroy() def test_dispatcher_failure_preserves_saved_speech_controls(self): from talking_clock.gui import SettingsDialog self.clock.settings = replace(self.clock.settings, module="offline", voice="Saved voice") self.mock_discover.side_effect = RuntimeError("Dispatcher unavailable") dialog = SettingsDialog(self.window, self.clock) try: self.wait_until(lambda: dialog.controls["voice"].get_sensitive()) self.assertIn("Saved selections are preserved", dialog.speech_status.get_text()) dialog.save() self.assertEqual(self.clock.settings.module, "offline") self.assertEqual(self.clock.settings.voice, "Saved voice") finally: dialog.destroy() def test_window_close_stops_audio_and_scheduler(self): self.clock.audio.stop = Mock() self.window.destroy() self.assertTrue(self.clock.stopped.is_set()) self.clock.audio.stop.assert_called_once() def test_control_q_closes_clock_from_settings_button(self): from talking_clock.gui import Gdk, Gtk self.clock.audio.stop = Mock() self.window.child_focus(Gtk.DirectionType.TAB_FORWARD) self.assertIsInstance(self.window.get_focus(), Gtk.Button) self.assertTrue(Gtk.accel_groups_activate(self.window, Gdk.KEY_q, Gdk.ModifierType.CONTROL_MASK)) self.assertTrue(self.clock.stopped.is_set()) self.clock.audio.stop.assert_called_once() def test_control_q_from_settings_stops_program_without_saving(self): from talking_clock.gui import Gdk, Gtk self.window.open_settings(None) self.window.dialog.controls["font_size"].set_value(100) self.assertTrue(Gtk.accel_groups_activate(self.window.dialog, Gdk.KEY_q, Gdk.ModifierType.CONTROL_MASK)) self.assertTrue(self.clock.stopped.is_set()) self.assertIsNone(self.window.dialog) self.assertEqual(self.clock.settings.font_size, 72) self.assertFalse(self.clock.config.path.exists()) def test_shutdown_with_settings_open_exits_main_loop(self): from talking_clock.gui import GLib, Gtk self.window.open_settings(None) self.assertIsNotNone(self.window.dialog) GLib.idle_add(lambda: self.window.destroy() or False) Gtk.main() self.assertIsNone(self.window.dialog) self.assertTrue(self.clock.stopped.is_set()) def test_detected_city_prefills_weather_fields(self): from talking_clock.gui import GLib, SettingsDialog self.clock.audio.weather.detect_location.return_value = { "location": "Raleigh, North Carolina, US", "latitude": "35.78", "longitude": "-78.64"} dialog = SettingsDialog(self.window, self.clock) try: deadline = time.monotonic() + 2 loop = GLib.MainLoop() def ready(): if dialog.controls["location"].get_text() or time.monotonic() >= deadline: loop.quit() return False return True GLib.timeout_add(10, ready) loop.run() self.assertEqual(dialog.controls["location"].get_text(), "Raleigh, North Carolina, US") self.assertEqual(dialog.controls["latitude"].get_text(), "35.78") self.assertEqual(dialog.controls["longitude"].get_text(), "-78.64") dialog.save() self.assertEqual(self.clock.config.load().latitude, "35.78") finally: dialog.destroy() def test_editing_city_does_not_reuse_old_coordinates(self): from talking_clock.gui import SettingsDialog self.clock.settings = replace(self.clock.settings, location="Raleigh", latitude="35.78", longitude="-78.64") dialog = SettingsDialog(self.window, self.clock) try: dialog.controls["location"].set_text("Berlin, Germany") dialog.save() self.assertEqual(self.clock.settings.location, "Berlin, Germany") self.assertEqual(self.clock.settings.latitude, "") self.assertEqual(self.clock.settings.longitude, "") finally: dialog.destroy() def test_gui_process_replaces_headless_process_and_remains_single_instance(self): from talking_clock.config import Config, Settings from talking_clock.runtime import send_command root = Path(__file__).resolve().parents[1] with tempfile.TemporaryDirectory() as directory: base = Path(directory) Config(base / "config/talking-clock").save(Settings(chime=False, speak=False)) environment = dict(os.environ, XDG_RUNTIME_DIR=directory, XDG_CONFIG_HOME=str(base / "config")) command = [sys.executable, str(root / "talking-clock")] processes = [] try: headless = subprocess.Popen(command + ["-H"], env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE) processes.append(headless) deadline = time.monotonic() + 5 while not (base / "talking-clock/control.sock").exists() and time.monotonic() < deadline: time.sleep(0.02) self.assertIsNone(headless.poll()) gui = subprocess.Popen(command, env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE) processes.append(gui) headless.communicate(timeout=5) self.assertEqual(headless.returncode, 0) deadline = time.monotonic() + 5 while time.monotonic() < deadline: if send_command(base / "talking-clock", "status") == "gui": break time.sleep(0.02) else: self.fail("Replacement GUI never became ready") self.assertIsNone(gui.poll()) duplicate = subprocess.run(command, env=environment, capture_output=True, timeout=5) self.assertEqual(duplicate.returncode, 0, duplicate.stderr) self.assertIsNone(gui.poll()) subprocess.run(command + ["-k"], env=environment, check=True, capture_output=True, timeout=5) gui.communicate(timeout=5) self.assertEqual(gui.returncode, 0) finally: for process in processes: if process.poll() is None: process.terminate() process.communicate(timeout=5) if __name__ == "__main__": unittest.main()