Files

361 lines
18 KiB
Python

"""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()