"""Focused behavior checks; audio commands are mocked so tests stay silent.""" from dataclasses import replace from datetime import datetime import json 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")) from talking_clock.audio import Audio, spoken_time from talking_clock.config import Config, Settings, read_legacy, resolve_soundpack, soundpacks from talking_clock.runtime import Clock, Instance, Scheduler, due, runtime_directory ROOT = Path(__file__).resolve().parents[1] class SettingsTests(unittest.TestCase): def test_round_trip_and_atomic_replacement(self): with tempfile.TemporaryDirectory() as directory: config = Config(directory) settings = Settings(module="espeak-ng", voice="English (America)", rate=37, font_size=100, weather_on_space=True) config.save(settings) self.assertEqual(config.load(), settings) config.save(replace(settings, font_size=120)) self.assertEqual(config.load().font_size, 120) self.assertEqual(list(Path(directory).iterdir()), [config.path]) self.assertEqual(config.path.stat().st_mode & 0o777, 0o600) def test_invalid_settings_preserve_existing_file(self): with tempfile.TemporaryDirectory() as directory: config = Config(directory) config.save(Settings()) with self.assertRaises(ValueError): config.save(Settings(font_size=0)) self.assertEqual(config.load().font_size, 72) for data in ({"hourly": "false"}, {"font_size": True}, {"rate": 101}, {"volume": "10"}, []): config.path.write_text(json.dumps(data)) with self.assertRaises(ValueError): config.load() def test_legacy_import_is_data_and_preserves_location(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "talking-clockrc" path.write_text('format=24\nspeak=false\nvoice="rhvoice-alan"\n' 'zipcode="Raleigh North Carolina"\nsound=play -q\n' 'ignored=$(touch /tmp/this-must-never-run)\n') settings = read_legacy(path) self.assertEqual(settings.voice, "") self.assertEqual(settings.module, "") self.assertFalse(settings.speak) self.assertTrue(settings.weather) self.assertEqual(settings.location, "Raleigh North Carolina") self.assertEqual(Config(directory).load(), settings) def test_user_soundpack_wins_and_xdg_is_respected(self): with tempfile.TemporaryDirectory() as directory: user, system = Path(directory) / "user", Path(directory) / "system" for root in (user, system): (root / "talking-clock/sounds/bells").mkdir(parents=True) with patch.dict(os.environ, {"XDG_DATA_HOME": str(user), "XDG_DATA_DIRS": str(system)}): self.assertEqual(soundpacks()["bells"], user / "talking-clock/sounds/bells") self.assertEqual(resolve_soundpack("bells"), user / "talking-clock/sounds/bells") self.assertTrue(resolve_soundpack("default").is_dir()) self.assertEqual(resolve_soundpack(str(system)), system) def test_live_reload_keeps_previous_settings_on_error(self): with tempfile.TemporaryDirectory() as directory: config = Config(directory) config.save(Settings(speak=False, chime=False)) instance = Mock() clock = Clock(config, config.load(), instance) config.save(Settings(speak=False, chime=False, font_size=100)) clock.tick() self.assertEqual(clock.settings.font_size, 100) config.path.write_text('{"font_size": "bad"}') clock.last_check = 0 with self.assertLogs("talking_clock.runtime", level="ERROR"): clock.tick() self.assertEqual(clock.settings.font_size, 100) clock.stop() class SchedulingTests(unittest.TestCase): def test_independent_intervals(self): for hourly in (False, True): for half in (False, True): for quarter in (False, True): settings = Settings(hourly=hourly, half_hour=half, quarter_hour=quarter) for minute, expected in ((0, hourly), (15, quarter), (30, half), (45, quarter), (17, False)): self.assertEqual(due(datetime(2026, 10, 23, 12, minute), settings), expected) def test_no_repeat_startup_burst_or_catch_up(self): audio = Mock() scheduler = Scheduler(audio) settings = Settings(hourly=True, half_hour=True, quarter_hour=True) scheduler.tick(datetime(2026, 10, 23, 12, 0, 40), settings) audio.announce.assert_not_called() scheduler.tick(datetime(2026, 10, 23, 12, 1), settings) scheduler.tick(datetime(2026, 10, 23, 12, 15), settings) scheduler.tick(datetime(2026, 10, 23, 12, 15, 1), settings) self.assertEqual(audio.announce.call_count, 1) scheduler.tick(datetime(2026, 10, 23, 12, 5), settings) # wall clock moves back scheduler.tick(datetime(2026, 10, 23, 12, 15), settings) self.assertEqual(audio.announce.call_count, 1) scheduler.tick(datetime(2026, 10, 23, 16, 2), settings) # resume after sleep self.assertEqual(audio.announce.call_count, 1) def test_midnight_and_time_formats(self): self.assertEqual(spoken_time(datetime(2026, 10, 23, 0, 0), "12"), "12 o'clock A M") self.assertEqual(spoken_time(datetime(2026, 10, 23, 0, 0), "24"), "zero hundred hours") self.assertEqual(spoken_time(datetime(2026, 10, 23, 13, 5), "24"), "13 oh 5") class AudioTests(unittest.TestCase): def test_default_hour_and_quarter_chimes(self): audio = Audio() audio.run = Mock(return_value=True) audio.chime(datetime(2026, 10, 23, 3, 0), Settings()) self.assertEqual([Path(call.args[0][-1]).name for call in audio.run.call_args_list], ["prepend.ogg", "bell.ogg", "bell.ogg", "bell.ogg"]) audio.run.reset_mock() audio.chime(datetime(2026, 10, 23, 3, 30), Settings()) self.assertEqual(Path(audio.run.call_args.args[0][-1]).name, "30.ogg") def test_recorded_hour_replaces_repeated_bell(self): with tempfile.TemporaryDirectory() as directory: (Path(directory) / "12.ogg").touch() audio = Audio() audio.run = Mock(return_value=True) audio.chime(datetime(2026, 10, 23, 0, 0), Settings(soundpack=directory)) self.assertEqual(audio.run.call_count, 1) self.assertEqual(audio.run.call_args.args[0][-1], str(Path(directory) / "12.ogg")) def test_speech_dispatcher_receives_all_controls_and_text_as_one_argument(self): audio = Audio() audio.run = Mock(return_value=True) with patch("talking_clock.audio.system_language", return_value="en-us"): audio.speak("12 o'clock A M", datetime(2026, 10, 23), Settings(module="espeak-ng", voice="English (America)", rate=30, pitch=-20, volume=40)) audio.run.assert_called_once_with([ "spd-say", "-w", "-P", "important", "-N", "talking-clock", "-l", "en-us", "-r", "30", "-p", "-20", "-i", "40", "-o", "espeak-ng", "-y", "English (America)", "--", "12 o'clock A M"]) def test_stop_terminates_active_process_and_blocks_new_audio(self): audio = Audio() process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], start_new_session=True) try: audio.process = process audio.stop() self.assertIsNotNone(process.poll()) with patch("talking_clock.audio.subprocess.Popen") as launch: self.assertFalse(audio.run(["must-not-run"])) launch.assert_not_called() finally: if process.poll() is None: process.kill() process.wait() class LifecycleTests(unittest.TestCase): def test_automatic_install_locations(self): cases = ( ("local-bin", "1000", (".local/bin",), False, ".local/bin", ".local/share"), ("home-bin", "1000", ("bin",), False, "bin", ".local/share"), ("prefer-local", "1000", ("bin", ".local/bin"), False, ".local/bin", ".local/share"), ("xdg-data", "1000", ("bin",), True, "bin", "custom data"), ("root", "0", (), False, "/usr/local/bin", "/usr/share"), ) for name, uid, path_dirs, custom_data, bin_suffix, data_suffix in cases: with self.subTest(name=name), tempfile.TemporaryDirectory() as directory: base = Path(directory) home = base / "user home" home.mkdir() tools = base / "tools" tools.mkdir() fake_id = tools / "id" fake_id.write_text(f"#!/bin/sh\nprintf '%s\\n' {uid}\n") fake_id.chmod(0o755) stage = base / "stage" environment = dict(os.environ, HOME=str(home), DESTDIR=str(stage), PATH=":".join([str(tools)] + [str(home / p) + "/" for p in path_dirs] + [os.defpath])) for key in ("PREFIX", "XDG_DATA_HOME", "PYTHONPATH"): environment.pop(key, None) if custom_data: environment["XDG_DATA_HOME"] = str(home / data_suffix) subprocess.run(["bash", str(ROOT / "INSTALL")], env=environment, check=True, capture_output=True, timeout=10) bindir = Path(bin_suffix) if uid == "0" else home / bin_suffix datadir = Path(data_suffix) if uid == "0" else home / data_suffix launcher = stage / bindir.relative_to("/") / "talking-clock" data = stage / datadir.relative_to("/") self.assertTrue((data / "applications/talking-clock.desktop").is_file()) result = subprocess.run([str(launcher), "-h"], env=environment, capture_output=True, text=True, timeout=5) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("-H", result.stdout) environment.update(PYTHONPATH=str(data / "talking-clock/python"), XDG_DATA_HOME=str(base / "empty-user"), XDG_DATA_DIRS=str(base / "empty-system")) result = subprocess.run([sys.executable, "-c", "from talking_clock.config import resolve_soundpack; " "print(resolve_soundpack('default'))"], env=environment, capture_output=True, text=True, timeout=5) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.strip(), str(data / "talking-clock/sounds/default")) def test_user_install_requires_home_bin_on_path_before_writing(self): with tempfile.TemporaryDirectory() as directory: base = Path(directory) tools = base / "tools" tools.mkdir() fake_id = tools / "id" fake_id.write_text("#!/bin/sh\nprintf '1000\\n'\n") fake_id.chmod(0o755) stage = base / "stage" environment = dict(os.environ, HOME=str(base / "home"), DESTDIR=str(stage), PATH=f"{tools}:{base}/home/bin-other:{os.defpath}") environment.pop("PREFIX", None) result = subprocess.run(["bash", str(ROOT / "INSTALL")], env=environment, capture_output=True, text=True, timeout=10) self.assertNotEqual(result.returncode, 0) self.assertIn("Add ~/.local/bin or ~/bin to PATH", result.stderr) self.assertFalse(stage.exists()) def test_staged_installer_launcher_and_soundpack(self): with tempfile.TemporaryDirectory() as directory: environment = dict(os.environ, DESTDIR=directory, PREFIX="/usr/local") subprocess.run(["bash", str(ROOT / "INSTALL")], env=environment, check=True, capture_output=True, timeout=10) prefix = Path(directory) / "usr/local" result = subprocess.run([str(prefix / "bin/talking-clock"), "-h"], capture_output=True, text=True, timeout=5) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("-H", result.stdout) environment.update(PYTHONPATH=str(prefix / "share/talking-clock/python"), XDG_DATA_HOME=str(Path(directory) / "empty-user"), XDG_DATA_DIRS=str(Path(directory) / "empty-system")) result = subprocess.run([sys.executable, "-c", "from talking_clock.config import resolve_soundpack; " "print(resolve_soundpack('default'))"], env=environment, capture_output=True, text=True, timeout=5) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.strip(), str(prefix / "share/talking-clock/sounds/default")) def test_single_instance_lock_and_stale_socket_recovery(self): with tempfile.TemporaryDirectory() as directory: first, second = Instance(Path(directory)), Instance(Path(directory)) try: self.assertTrue(first.acquire()) self.assertFalse(second.acquire()) first.close() self.assertTrue(second.acquire()) finally: first.close() second.close() def test_reject_non_private_runtime_directory(self): with tempfile.TemporaryDirectory() as directory: target = Path(directory) / "talking-clock" target.mkdir(mode=0o755) target.chmod(0o755) with patch.dict(os.environ, {"XDG_RUNTIME_DIR": directory}): with self.assertRaises(OSError): runtime_directory() def test_headless_duplicate_kill_and_restart_without_gtk(self): with tempfile.TemporaryDirectory() as directory: base = Path(directory) config = Config(base / "config/talking-clock") config.save(Settings(chime=False, speak=False)) environment = dict(os.environ, XDG_CONFIG_HOME=str(base / "config"), XDG_RUNTIME_DIR=directory, DISPLAY="", WAYLAND_DISPLAY="") command = [sys.executable, str(ROOT / "talking-clock")] for _ in range(2): process = subprocess.Popen(command + ["-H"], env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE) try: socket_path = base / "talking-clock/control.sock" for _ in range(100): if socket_path.exists() or process.poll() is not None: break time.sleep(0.02) self.assertIsNone(process.poll()) result = subprocess.run(command + ["-H"], env=environment, capture_output=True, text=True, timeout=5) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("already running", result.stdout) # A failed attempt to open a display must leave headless mode alive. result = subprocess.run(command, env=environment, capture_output=True, text=True, timeout=5) self.assertEqual(result.returncode, 1, result.stderr) self.assertIsNone(process.poll()) result = subprocess.run(command + ["-k"], env=environment, capture_output=True, text=True, timeout=5) self.assertEqual(result.returncode, 0, result.stderr) _, errors = process.communicate(timeout=5) self.assertEqual(process.returncode, 0, errors.decode()) self.assertFalse(socket_path.exists()) finally: if process.poll() is None: process.terminate() process.communicate(timeout=5) result = subprocess.run(command + ["-k"], env=environment, capture_output=True, timeout=5) self.assertEqual(result.returncode, 0) if __name__ == "__main__": unittest.main()