diff --git a/README.md b/README.md index 1390639..fc014a2 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,10 @@ Settings are stored in: - Linux: `~/.config/AccessiClock/config.json` - Portable mode: `./data/config.json` +## Migration Notes + +- Current migration parity checklist: `docs/wxpython-migration-parity-checklist.md` + ## Development ### Running Tests diff --git a/docs/wxpython-migration-parity-checklist.md b/docs/wxpython-migration-parity-checklist.md new file mode 100644 index 0000000..fafb550 --- /dev/null +++ b/docs/wxpython-migration-parity-checklist.md @@ -0,0 +1,52 @@ +# AccessiClock wxPython Migration: Feature Inventory + Parity Checklist + +Branch: `rewrite/wxpython-core-manual` +Base: `dev` + +## Scope for this phase (must-have) + +These are the baseline behaviors required for a usable first wxPython version. + +- [x] Native wxPython app entrypoint (`accessiclock.main:main`) +- [x] Main window opens and can be navigated with keyboard only +- [x] Predictable startup focus (initial focus lands on a stable control) +- [x] Config path setup (portable + normal mode) +- [x] Logging setup (file + console) +- [x] Settings load/save scaffold with safe defaults +- [x] Startup/shutdown flow scaffold in app class +- [x] Initial keyboard shortcut map documented in code and README +- [x] Smoke tests for settings/shortcuts/logging scaffolding + +## Existing feature inventory + +### Core clock behavior +- [x] Time display updates every second +- [x] Hourly chime logic +- [x] Half-hour chime logic +- [x] Quarter-hour chime logic +- [x] Quiet hours in clock service + +### Audio + voice +- [x] Sound playback through `AudioPlayer` +- [x] Clock pack sound lookup +- [x] Test chime action +- [x] TTS announce current time + +### Accessibility + UX +- [x] Keyboard reachable controls (Tab order + mnemonics) +- [x] Screen-reader-friendly labels/names on key controls +- [x] Status text updates for user feedback +- [x] Focus-safe startup behavior + +## Later phase items (not required for this run) + +- [ ] Full settings dialog parity audit and cleanup +- [ ] Clock manager UX polish and validation messaging +- [ ] Better accessibility pass for dialog content and error states +- [ ] Structured app state object (if needed after more features land) +- [ ] CI matrix that runs GUI smoke checks on Windows +- [ ] Packaging polish (PyInstaller/win installer flow) + +## Notes + +This phase intentionally keeps architecture lean: minimal new modules for logging, settings persistence, and shortcut mapping. No new heavy framework layer was introduced. diff --git a/src/accessiclock/app.py b/src/accessiclock/app.py index 306cf11..666b649 100644 --- a/src/accessiclock/app.py +++ b/src/accessiclock/app.py @@ -1,17 +1,15 @@ -""" -AccessiClock wxPython application. - -Main application class with screen reader accessibility support. -""" +"""AccessiClock wxPython application.""" from __future__ import annotations import logging +from datetime import time as dt_time from typing import TYPE_CHECKING import wx from .audio.tts_engine import TTSEngine +from .core.settings import AppSettings, load_settings, save_settings from .paths import Paths from .services.clock_pack_loader import ClockPackLoader from .services.clock_service import ClockService @@ -26,351 +24,179 @@ class AccessiClockApp(wx.App): """AccessiClock application using wxPython.""" def __init__(self, portable_mode: bool = False): - """ - Initialize the AccessiClock application. - - Args: - portable_mode: If True, use portable mode (config alongside app). - """ self._portable_mode = portable_mode - - # Set up paths self.paths = Paths(portable_mode=portable_mode) - # UI components (initialized in OnInit) self.main_window: MainWindow | None = None - - # Audio player (initialized in OnInit) self.audio_player = None - - # TTS engine (initialized in OnInit) self.tts_engine: TTSEngine | None = None - - # Services self.clock_service: ClockService | None = None self.clock_pack_loader: ClockPackLoader | None = None - # Configuration - self.config: dict = {} - - # Clock state - self.current_volume: int = 50 - self.selected_clock: str = "default" - self.chime_hourly: bool = True - self.chime_half_hour: bool = False - self.chime_quarter_hour: bool = False + self.settings = AppSettings() + self.current_volume = self.settings.volume + self.selected_clock = self.settings.clock + self.chime_hourly = self.settings.chime_hourly + self.chime_half_hour = self.settings.chime_half_hour + self.chime_quarter_hour = self.settings.chime_quarter_hour super().__init__() def OnInit(self) -> bool: - """Initialize the application (wxPython entry point).""" - logger.info("Starting AccessiClock application (wxPython)") - + """Initialize the app and create the main window.""" + logger.info("Starting AccessiClock wxPython app") try: - # Initialize services - self._init_services() - - # Initialize audio player - self._init_audio() - - # Initialize TTS engine - self._init_tts() - - # Load configuration - self._load_config() - - # Sync service settings with config - self._sync_service_settings() - - # Create main window - from .ui.main_window import MainWindow + self._startup() + return True + except Exception: + logger.exception("Startup failed") + wx.MessageBox("AccessiClock could not start. See log file for details.", "Startup Error") + return False - self.main_window = MainWindow(self) - self.main_window.Show() - self.SetTopWindow(self.main_window) + def _startup(self) -> None: + self._init_services() + self._init_audio() + self._init_tts() + self._load_config() + self._sync_service_settings() - logger.info("AccessiClock initialized successfully") - return True + from .ui.main_window import MainWindow - except Exception as e: - logger.exception(f"Failed to initialize AccessiClock: {e}") - wx.MessageBox( - f"Failed to start AccessiClock:\n\n{e}", - "Startup Error", - wx.OK | wx.ICON_ERROR, - ) - return False + self.main_window = MainWindow(self) + self.main_window.Show() + self.SetTopWindow(self.main_window) def _init_services(self) -> None: - """Initialize application services.""" - # Initialize clock service self.clock_service = ClockService() - logger.info("Clock service initialized") - - # Initialize clock pack loader self.clock_pack_loader = ClockPackLoader(self.paths.clocks_dir) self.clock_pack_loader.discover_packs() - logger.info(f"Discovered {len(self.clock_pack_loader._cache)} clock packs") - - def _sync_service_settings(self) -> None: - """Sync service settings with loaded configuration.""" - if self.clock_service: - self.clock_service.chime_hourly = self.chime_hourly - self.clock_service.chime_half_hour = self.chime_half_hour - self.clock_service.chime_quarter_hour = self.chime_quarter_hour - logger.debug("Clock service settings synced with config") def _init_audio(self) -> None: - """Initialize the audio player.""" try: from .audio.player import AudioPlayer self.audio_player = AudioPlayer(volume_percent=self.current_volume) - logger.info("Audio player initialized") - except Exception as e: - logger.warning(f"Audio player initialization failed: {e}") + except Exception: + logger.warning("Audio player unavailable", exc_info=True) self.audio_player = None def _init_tts(self) -> None: - """Initialize the TTS engine.""" try: self.tts_engine = TTSEngine() - logger.info(f"TTS engine initialized ({self.tts_engine.engine_type})") - except Exception as e: - logger.warning(f"TTS engine initialization failed: {e}") + except Exception: + logger.warning("TTS unavailable", exc_info=True) self.tts_engine = None + def _sync_service_settings(self) -> None: + if not self.clock_service: + return + self.clock_service.chime_hourly = self.chime_hourly + self.clock_service.chime_half_hour = self.chime_half_hour + self.clock_service.chime_quarter_hour = self.chime_quarter_hour + def _load_config(self) -> None: - """Load configuration from file.""" - config_file = self.paths.config_file - if config_file.exists(): + self.settings = load_settings(self.paths.config_file) + + self.current_volume = self.settings.volume + self.selected_clock = self.settings.clock + self.chime_hourly = self.settings.chime_hourly + self.chime_half_hour = self.settings.chime_half_hour + self.chime_quarter_hour = self.settings.chime_quarter_hour + + if self.clock_service and self.settings.quiet_hours_enabled: try: - import json - - with open(config_file, encoding="utf-8") as f: - self.config = json.load(f) - - # Apply loaded settings - self.current_volume = self.config.get("volume", 50) - self.selected_clock = self.config.get("clock", "default") - self.chime_hourly = self.config.get("chime_hourly", True) - self.chime_half_hour = self.config.get("chime_half_hour", False) - self.chime_quarter_hour = self.config.get("chime_quarter_hour", False) - - # Restore quiet hours - if self.config.get("quiet_hours_enabled", False) and self.clock_service: - from datetime import time as dt_time - try: - sh, sm = map(int, self.config["quiet_start"].split(":")) - eh, em = map(int, self.config["quiet_end"].split(":")) - self.clock_service.set_quiet_hours(dt_time(sh, sm), dt_time(eh, em)) - except (KeyError, ValueError) as e: - logger.warning(f"Failed to restore quiet hours: {e}") - elif self.clock_service: - self.clock_service.quiet_hours_enabled = False - - logger.info(f"Configuration loaded from {config_file}") - except Exception as e: - logger.warning(f"Failed to load config: {e}") + sh, sm = map(int, self.settings.quiet_start.split(":")) + eh, em = map(int, self.settings.quiet_end.split(":")) + self.clock_service.set_quiet_hours(dt_time(sh, sm), dt_time(eh, em)) + except (TypeError, ValueError): + logger.warning("Invalid quiet hours in config; disabling") + self.clock_service.quiet_hours_enabled = False def save_config(self) -> None: - """Save configuration to file.""" - import json - - quiet_config = {} if self.clock_service and self.clock_service.quiet_hours_enabled: - quiet_config = { - "quiet_hours_enabled": True, - "quiet_start": self.clock_service.quiet_start.strftime("%H:%M"), - "quiet_end": self.clock_service.quiet_end.strftime("%H:%M"), - } + quiet_enabled = True + quiet_start = self.clock_service.quiet_start.strftime("%H:%M") + quiet_end = self.clock_service.quiet_end.strftime("%H:%M") else: - quiet_config = {"quiet_hours_enabled": False} - - self.config.update( - { - "volume": self.current_volume, - "clock": self.selected_clock, - "chime_hourly": self.chime_hourly, - "chime_half_hour": self.chime_half_hour, - "chime_quarter_hour": self.chime_quarter_hour, - **quiet_config, - } + quiet_enabled = False + quiet_start = self.settings.quiet_start + quiet_end = self.settings.quiet_end + + self.settings = AppSettings( + volume=self.current_volume, + clock=self.selected_clock, + chime_hourly=self.chime_hourly, + chime_half_hour=self.chime_half_hour, + chime_quarter_hour=self.chime_quarter_hour, + quiet_hours_enabled=quiet_enabled, + quiet_start=quiet_start, + quiet_end=quiet_end, ) - - # Sync with clock service self._sync_service_settings() - - try: - config_file = self.paths.config_file - with open(config_file, "w", encoding="utf-8") as f: - json.dump(self.config, f, indent=2) - logger.info(f"Configuration saved to {config_file}") - except Exception as e: - logger.error(f"Failed to save config: {e}") + save_settings(self.paths.config_file, self.settings) def set_volume(self, volume: int) -> None: - """Set the audio volume.""" self.current_volume = max(0, min(100, volume)) if self.audio_player: self.audio_player.set_volume(self.current_volume) self.save_config() def play_chime(self, chime_type: str) -> bool: - """ - Play a chime sound from the selected clock pack. - - Args: - chime_type: Type of chime ("hour", "half_hour", "quarter_hour", "preview"). - - Returns: - True if successful, False otherwise. - """ - if not self.audio_player: - logger.warning("No audio player available") - return False - - if not self.clock_pack_loader: - logger.warning("No clock pack loader available") + if not self.audio_player or not self.clock_pack_loader: return False - try: pack_info = self.clock_pack_loader.get_pack(self.selected_clock) if not pack_info: - logger.warning(f"Clock pack not found: {self.selected_clock}") return False - sound_path = pack_info.get_sound_path(chime_type) if not sound_path or not sound_path.exists(): - logger.warning(f"Sound not found: {chime_type} in {self.selected_clock}") return False - self.audio_player.play_sound(str(sound_path)) - logger.info(f"Playing {chime_type} chime from {self.selected_clock}") return True - - except Exception as e: - logger.error(f"Failed to play chime: {e}") + except Exception: + logger.warning("Unable to play %s chime", chime_type, exc_info=True) return False def play_test_sound(self) -> bool: - """Play a test/preview sound. Returns True if successful.""" - # Try to play preview from selected clock pack - if self.play_chime("preview"): - return True - - # Fall back to hour chime - if self.play_chime("hour"): - return True - - # Last resort: try built-in test sound - if not self.audio_player: - logger.warning("No audio player available") - return False - - try: - test_sound = self.paths.app_dir / "audio" / "test_sound.wav" - if test_sound.exists(): - self.audio_player.play_sound(str(test_sound)) - return True - else: - logger.warning(f"Test sound not found at {test_sound}") - return False - except Exception as e: - logger.error(f"Failed to play test sound: {e}") - return False + return self.play_chime("preview") or self.play_chime("hour") def announce_time(self, style: str = "simple") -> bool: - """ - Announce the current time using TTS. - - Args: - style: Time format style ("simple", "natural", "precise"). - - Returns: - True if announced, False if TTS unavailable. - """ if not self.tts_engine: - logger.warning("TTS engine not available") return False - from datetime import datetime - current_time = datetime.now().time() - self.tts_engine.speak_time(current_time, style=style) - logger.info(f"Time announced: {current_time.strftime('%I:%M %p')}") + self.tts_engine.speak_time(datetime.now().time(), style=style) return True def check_and_play_chime(self) -> str | None: - """ - Check if a chime should play now and play it. - - Called by the main window's timer tick. - - Returns: - The type of chime played, or None if no chime. - """ if not self.clock_service: return None - from datetime import datetime - current_time = datetime.now().time() - chime_type = self.clock_service.should_chime_now(current_time) - + now = datetime.now().time() + chime_type = self.clock_service.should_chime_now(now) if chime_type and self.play_chime(chime_type): - self.clock_service.mark_chimed(current_time) + self.clock_service.mark_chimed(now) return chime_type - return None def get_available_clocks(self) -> list[str]: - """ - Get list of available clock pack names. - - Returns: - List of clock pack display names. - """ - if not self.clock_pack_loader: - return ["Default"] - - packs = self.clock_pack_loader._cache - if not packs: + if not self.clock_pack_loader or not self.clock_pack_loader._cache: return ["Default"] - - return [info.name for info in packs.values()] - - def get_clock_pack_ids(self) -> list[str]: - """ - Get list of available clock pack IDs. - - Returns: - List of clock pack IDs (directory names). - """ - if not self.clock_pack_loader: - return ["default"] - - return list(self.clock_pack_loader._cache.keys()) or ["default"] + return [info.name for info in self.clock_pack_loader._cache.values()] def OnExit(self) -> int: - """Clean up before exit.""" - logger.info("AccessiClock shutting down") - - # Save configuration + logger.info("Shutting down AccessiClock") self.save_config() - # Clean up audio if self.audio_player: try: self.audio_player.cleanup() - except Exception as e: - logger.warning(f"Error cleaning up audio: {e}") - - # Clean up TTS + except Exception: + logger.warning("Audio cleanup failed", exc_info=True) if self.tts_engine: try: self.tts_engine.cleanup() - except Exception as e: - logger.warning(f"Error cleaning up TTS: {e}") - + except Exception: + logger.warning("TTS cleanup failed", exc_info=True) return 0 diff --git a/src/accessiclock/core/__init__.py b/src/accessiclock/core/__init__.py new file mode 100644 index 0000000..230b44f --- /dev/null +++ b/src/accessiclock/core/__init__.py @@ -0,0 +1,13 @@ +"""Core scaffolding for AccessiClock wxPython app.""" + +from .settings import AppSettings, load_settings, save_settings +from .shortcuts import Shortcut, build_shortcut_help, default_shortcuts + +__all__ = [ + "AppSettings", + "Shortcut", + "build_shortcut_help", + "default_shortcuts", + "load_settings", + "save_settings", +] diff --git a/src/accessiclock/core/logging_setup.py b/src/accessiclock/core/logging_setup.py new file mode 100644 index 0000000..e7e2346 --- /dev/null +++ b/src/accessiclock/core/logging_setup.py @@ -0,0 +1,28 @@ +"""Logging bootstrap for AccessiClock.""" + +from __future__ import annotations + +import logging +from pathlib import Path + + +def configure_logging(logs_dir: Path, level: int = logging.INFO) -> Path: + """Configure root logging to file + console and return log file path.""" + logs_dir.mkdir(parents=True, exist_ok=True) + log_file = logs_dir / "accessiclock.log" + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.setLevel(level) + + formatter = logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s") + + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) + + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + root_logger.addHandler(console_handler) + + return log_file diff --git a/src/accessiclock/core/settings.py b/src/accessiclock/core/settings.py new file mode 100644 index 0000000..80354af --- /dev/null +++ b/src/accessiclock/core/settings.py @@ -0,0 +1,66 @@ +"""Settings I/O and simple validation for AccessiClock.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass +class AppSettings: + """Persistent user settings used by the app.""" + + volume: int = 50 + clock: str = "default" + chime_hourly: bool = True + chime_half_hour: bool = False + chime_quarter_hour: bool = False + quiet_hours_enabled: bool = False + quiet_start: str = "22:00" + quiet_end: str = "07:00" + + @classmethod + def from_dict(cls, raw: dict) -> AppSettings: + """Build settings from plain dict with safe defaults.""" + settings = cls( + volume=_clamp_volume(raw.get("volume", 50)), + clock=str(raw.get("clock", "default") or "default"), + chime_hourly=bool(raw.get("chime_hourly", True)), + chime_half_hour=bool(raw.get("chime_half_hour", False)), + chime_quarter_hour=bool(raw.get("chime_quarter_hour", False)), + quiet_hours_enabled=bool(raw.get("quiet_hours_enabled", False)), + quiet_start=str(raw.get("quiet_start", "22:00")), + quiet_end=str(raw.get("quiet_end", "07:00")), + ) + return settings + + def to_dict(self) -> dict: + """Serialize settings to plain dict.""" + return asdict(self) + + +def _clamp_volume(value: object) -> int: + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return 50 + + +def load_settings(config_file: Path) -> AppSettings: + """Load settings from config file. Returns defaults on errors.""" + if not config_file.exists(): + return AppSettings() + + try: + with open(config_file, encoding="utf-8") as handle: + return AppSettings.from_dict(json.load(handle)) + except (OSError, json.JSONDecodeError, TypeError): + return AppSettings() + + +def save_settings(config_file: Path, settings: AppSettings) -> None: + """Save settings to disk, creating parent directories as needed.""" + config_file.parent.mkdir(parents=True, exist_ok=True) + with open(config_file, "w", encoding="utf-8") as handle: + json.dump(settings.to_dict(), handle, indent=2) diff --git a/src/accessiclock/core/shortcuts.py b/src/accessiclock/core/shortcuts.py new file mode 100644 index 0000000..51dbb22 --- /dev/null +++ b/src/accessiclock/core/shortcuts.py @@ -0,0 +1,27 @@ +"""Keyboard shortcut map for AccessiClock UI.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Shortcut: + keys: str + action: str + + +def default_shortcuts() -> list[Shortcut]: + """Return current keyboard shortcut map.""" + return [ + Shortcut("F5", "Test chime"), + Shortcut("Space", "Announce current time"), + Shortcut("Ctrl+,", "Open settings"), + Shortcut("Alt+F4", "Exit application"), + Shortcut("Tab / Shift+Tab", "Move focus between controls"), + ] + + +def build_shortcut_help() -> str: + """Create readable help text for the status line / docs.""" + return " | ".join(f"{s.keys}: {s.action}" for s in default_shortcuts()) diff --git a/src/accessiclock/main.py b/src/accessiclock/main.py index 2799f1a..3bc322f 100644 --- a/src/accessiclock/main.py +++ b/src/accessiclock/main.py @@ -1,30 +1,47 @@ """Main entry point for AccessiClock.""" +from __future__ import annotations + +import argparse import logging import sys +from .core.logging_setup import configure_logging +from .paths import Paths -def main() -> int: - """Run the AccessiClock application.""" - # Configure logging - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], +logger = logging.getLogger(__name__) + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="AccessiClock") + parser.add_argument( + "--portable", + action="store_true", + help="Store config and logs beside the app (portable mode)", ) - logger = logging.getLogger(__name__) - logger.info("Starting AccessiClock") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Run the AccessiClock application.""" + args = _parse_args(argv if argv is not None else sys.argv[1:]) + + paths = Paths(portable_mode=args.portable) + log_file = configure_logging(paths.logs_dir) + logger.info("Starting AccessiClock (portable=%s)", args.portable) + logger.info("Logging to %s", log_file) try: from accessiclock.app import AccessiClockApp - app = AccessiClockApp() + app = AccessiClockApp(portable_mode=args.portable) app.MainLoop() + logger.info("AccessiClock exited cleanly") return 0 - except Exception as e: - logger.exception(f"Failed to start AccessiClock: {e}") + except Exception: + logger.exception("Failed to start AccessiClock") return 1 if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/src/accessiclock/ui/main_window.py b/src/accessiclock/ui/main_window.py index 82ef8ea..636809d 100644 --- a/src/accessiclock/ui/main_window.py +++ b/src/accessiclock/ui/main_window.py @@ -14,6 +14,7 @@ import wx from ..constants import TIME_FORMAT_12H, VOLUME_LEVELS +from ..core.shortcuts import build_shortcut_help if TYPE_CHECKING: from ..app import AccessiClockApp @@ -60,8 +61,9 @@ def __init__(self, app: AccessiClockApp): # Start clock timer self._start_clock_timer() - # Center window + # Center window and set predictable focus once shown. self.Centre() + wx.CallAfter(self._set_initial_focus) logger.info("Main window created") @@ -217,9 +219,14 @@ def _bind_events(self) -> None: self.settings_button.Bind(wx.EVT_BUTTON, self._on_settings) def _setup_keyboard_shortcuts(self) -> None: - """Set up keyboard shortcuts.""" - # Keyboard shortcuts are handled via menu accelerators (F5, Space, etc.) - # defined in _create_menu_bar() + """Set up keyboard shortcuts and announce map in logs/status.""" + logger.info("Shortcut map: %s", build_shortcut_help()) + + def _set_initial_focus(self) -> None: + """Move focus to a stable control to help screen reader users on startup.""" + if self.clock_selection and self.clock_selection.IsShownOnScreen(): + self.clock_selection.SetFocus() + self._set_status("Ready. Focus is on clock selection. Use Tab to navigate.") def _start_clock_timer(self) -> None: """Start the clock update timer.""" diff --git a/tests/core/test_logging_scaffold.py b/tests/core/test_logging_scaffold.py new file mode 100644 index 0000000..4cb50c6 --- /dev/null +++ b/tests/core/test_logging_scaffold.py @@ -0,0 +1,13 @@ +import logging +from pathlib import Path + +from accessiclock.core.logging_setup import configure_logging + + +def test_configure_logging_creates_log_file(tmp_path: Path): + log_file = configure_logging(tmp_path) + + logging.getLogger("accessiclock.test").info("hello log") + + assert log_file.exists() + assert "hello log" in log_file.read_text(encoding="utf-8") diff --git a/tests/core/test_settings_scaffold.py b/tests/core/test_settings_scaffold.py new file mode 100644 index 0000000..0a03d2b --- /dev/null +++ b/tests/core/test_settings_scaffold.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from accessiclock.core.settings import AppSettings, load_settings, save_settings + + +def test_load_settings_defaults_for_missing_file(tmp_path: Path): + settings = load_settings(tmp_path / "missing.json") + assert settings == AppSettings() + + +def test_load_settings_clamps_volume(tmp_path: Path): + config_file = tmp_path / "config.json" + config_file.write_text('{"volume": 999, "clock": "digital"}', encoding="utf-8") + + settings = load_settings(config_file) + assert settings.volume == 100 + assert settings.clock == "digital" + + +def test_save_and_load_round_trip(tmp_path: Path): + config_file = tmp_path / "nested" / "config.json" + expected = AppSettings(volume=25, clock="westminster", chime_half_hour=True) + + save_settings(config_file, expected) + loaded = load_settings(config_file) + + assert loaded == expected diff --git a/tests/core/test_shortcuts_scaffold.py b/tests/core/test_shortcuts_scaffold.py new file mode 100644 index 0000000..ab7b773 --- /dev/null +++ b/tests/core/test_shortcuts_scaffold.py @@ -0,0 +1,14 @@ +from accessiclock.core.shortcuts import build_shortcut_help, default_shortcuts + + +def test_default_shortcuts_contains_core_actions(): + names = [shortcut.action for shortcut in default_shortcuts()] + assert "Test chime" in names + assert "Announce current time" in names + + +def test_build_shortcut_help_is_readable_text(): + help_text = build_shortcut_help() + assert "F5" in help_text + assert "Ctrl+," in help_text + assert "|" in help_text