361 lines
18 KiB
Python
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()
|