From b599a2594506faaf339bd87fb965434db11038dc Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 12 May 2026 17:23:50 -0400 Subject: [PATCH] Add more local sourced directories for scripts and sounds meaning each user can have own scripts and sound themes when using -x. Hopefully fixed the remainder of the random freeze bug. --- config/settings/settings.conf | 6 +- .../commands/commands/subprocess.py | 8 +- .../KEY/fenrir/sound/select_theme.py | 13 +- src/fenrirscreenreader/core/commandManager.py | 170 ++-- src/fenrirscreenreader/core/outputManager.py | 12 +- .../core/settingsManager.py | 136 ++-- .../core/settingsManager.py.bak | 742 ------------------ src/fenrirscreenreader/core/soundDriver.py | 2 +- .../screenDriver/ptyDriver.py | 29 +- .../soundDriver/gstreamerDriver.py | 2 +- tests/unit/test_local_resources.py | 122 +++ tests/unit/test_output_manager.py | 48 ++ tests/unit/test_pty_terminal_sequences.py | 43 +- tests/unit/test_sound_drivers.py | 29 + tests/unit/test_subprocess_command.py | 33 + 15 files changed, 516 insertions(+), 879 deletions(-) delete mode 100644 src/fenrirscreenreader/core/settingsManager.py.bak create mode 100644 tests/unit/test_local_resources.py create mode 100644 tests/unit/test_output_manager.py create mode 100644 tests/unit/test_sound_drivers.py create mode 100644 tests/unit/test_subprocess_command.py diff --git a/config/settings/settings.conf b/config/settings/settings.conf index 2ff60347..df2d9f3e 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -9,7 +9,8 @@ driver=gstreamerDriver #driver=genericDriver # Sound themes. These are the pack of sounds used for sound alerts. -# Sound packs may be located at /usr/share/sounds +# Sound packs may be located at ~/.local/stormux/fenrir/sounds, +# /usr/share/sounds/fenrir, or /usr/share/sounds/fenrirscreenreader. theme=default # Sound volume controls how loud the sounds for your selected soundpack are. @@ -201,7 +202,8 @@ date_format=%%A, %%B %%d, %%Y auto_spell_check=True # Language for spell checking (format: language_COUNTRY, e.g., en_US, en_GB, es_ES) spell_check_language=en_US -# path for your scripts "script_keys" functionality +# path for your scripts "script_keys" functionality. +# User-local scripts in ~/.local/stormux/fenrir are loaded first. script_path=/usr/share/fenrirscreenreader/scripts # Override default commands or add custom commands without modifying Fenrir installation # Leave empty to use default commands only diff --git a/src/fenrirscreenreader/commands/commands/subprocess.py b/src/fenrirscreenreader/commands/commands/subprocess.py index 3c9ab294..450fb1be 100644 --- a/src/fenrirscreenreader/commands/commands/subprocess.py +++ b/src/fenrirscreenreader/commands/commands/subprocess.py @@ -6,7 +6,6 @@ import _thread import os -import subprocess from subprocess import PIPE from subprocess import Popen @@ -53,10 +52,11 @@ class command: def _thread_run(self): try: - callstring = ( - self.script_path + " " + self.env["general"]["curr_user"] + p = Popen( + [self.script_path, self.env["general"]["curr_user"]], + stdout=PIPE, + stderr=PIPE, ) - p = Popen(callstring, stdout=PIPE, stderr=PIPE, shell=True) stdout, stderr = p.communicate() stdout = stdout.decode("utf-8") stderr = stderr.decode("utf-8") diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py index be50a087..50f8e5b5 100644 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py @@ -22,23 +22,24 @@ class command(config_command): def run(self): current_theme = self.get_setting("sound", "theme", "default") + current_theme_name = os.path.basename( + os.path.normpath(current_theme) + ) # Present current theme self.present_text(f"Current sound theme: {current_theme}") # Look for available sound themes - sound_paths = [ - "/usr/share/sounds", - "/usr/share/fenrirscreenreader/sounds", - os.path.expanduser("~/.local/share/fenrirscreenreader/sounds"), - ] + sound_paths = self.env[ + "runtime" + ]["SettingsManager"].get_sound_theme_roots() available_themes = self.get_available_themes(sound_paths) if len(available_themes) > 1: # For this implementation, cycle through available themes try: - current_index = available_themes.index(current_theme) + current_index = available_themes.index(current_theme_name) next_index = (current_index + 1) % len(available_themes) new_theme = available_themes[next_index] except ValueError: diff --git a/src/fenrirscreenreader/core/commandManager.py b/src/fenrirscreenreader/core/commandManager.py index ad3eafe6..46a84b06 100644 --- a/src/fenrirscreenreader/core/commandManager.py +++ b/src/fenrirscreenreader/core/commandManager.py @@ -174,90 +174,121 @@ class CommandManager: ) continue + def get_script_paths(self, script_path=""): + if script_path: + candidate_paths = [script_path] + else: + settings_manager = self.env["runtime"]["SettingsManager"] + candidate_paths = [ + settings_manager.get_user_script_path(), + settings_manager.get_setting("general", "script_path"), + os.path.join(fenrir_path, "../../config/scripts/"), + ] + + script_paths = [] + seen_paths = set() + for path in candidate_paths: + if not path: + continue + normalized_path = os.path.abspath(os.path.expanduser(path)) + if normalized_path in seen_paths: + continue + script_paths.append(normalized_path) + seen_paths.add(normalized_path) + return script_paths + def load_script_commands(self, section="commands", script_path=""): - if script_path == "": - script_path = self.env["runtime"]["SettingsManager"].get_setting( - "general", "script_path" - ) - if not script_path.endswith("/"): - script_path += "/" - if not os.path.exists(script_path): - if os.path.exists(fenrir_path + "/../../config/scripts/"): - script_path = fenrir_path + "/../../config/scripts/" - else: - self.env["runtime"]["DebugManager"].write_debug_out( - "scriptpath not exists:" + script_path, - debug.DebugLevel.WARNING, + script_paths = self.get_script_paths(script_path) + loaded_any_path = False + loaded_script_names = set() + for path in script_paths: + loaded_any_path = ( + self.load_script_commands_from_path( + section, path, loaded_script_names ) - return + or loaded_any_path + ) + if not loaded_any_path: + self.env["runtime"]["DebugManager"].write_debug_out( + "No script paths available:" + str(script_paths), + debug.DebugLevel.WARNING, + ) + + def load_script_commands_from_path( + self, section, script_path, loaded_script_names=None + ): + if loaded_script_names is None: + loaded_script_names = set() + if not os.path.exists(script_path): + return False if not os.path.isdir(script_path): self.env["runtime"]["DebugManager"].write_debug_out( "scriptpath not a directory:" + script_path, debug.DebugLevel.ERROR, ) - return + return False if not os.access(script_path, os.R_OK): self.env["runtime"]["DebugManager"].write_debug_out( "scriptpath not readable:" + script_path, debug.DebugLevel.ERROR, ) - return - command_list = glob.glob(script_path + "*") + return False + command_list = sorted(glob.glob(os.path.join(script_path, "*"))) sub_command = fenrir_path + "/commands/commands/subprocess.py" for command in command_list: - invalid = False try: + if not os.path.isfile(command): + continue file_name, file_extension = os.path.splitext(command) file_name = file_name.split("/")[-1] if file_name.startswith("__"): continue - if file_name.upper() in self.env["commands"][section]: + command_name = file_name.upper() + script_name = self.get_script_name(file_name) + if script_name in loaded_script_names: + self.env["runtime"]["DebugManager"].write_debug_out( + "Skip script with duplicate script name:" + + command_name, + debug.DebugLevel.INFO, + ) + continue + if command_name in self.env["commands"][section]: + self.env["runtime"]["DebugManager"].write_debug_out( + "Skip script with duplicate command name:" + + command_name, + debug.DebugLevel.INFO, + ) + continue + shortcut = self.get_script_shortcut(file_name) + if not shortcut: + continue + shortcut_key = str(shortcut) + if shortcut_key in self.env["bindings"]: + self.env["runtime"]["DebugManager"].write_debug_out( + "Skip script with duplicate shortcut:" + + command_name + + " " + + shortcut_key, + debug.DebugLevel.INFO, + ) continue command_mod = module_utils.import_module( file_name, sub_command ) - self.env["commands"][section][ - file_name.upper() - ] = command_mod.command() - self.env["commands"][section][file_name.upper()].initialize( + self.env["commands"][section][command_name] = ( + command_mod.command() + ) + self.env["commands"][section][command_name].initialize( self.env, command ) + self.env["bindings"][shortcut_key] = command_name + self.env["rawBindings"][shortcut_key] = shortcut + loaded_script_names.add(script_name) self.env["runtime"]["DebugManager"].write_debug_out( - "Load script:" + section + "." + file_name.upper(), + "Load script:" + section + "." + command_name, debug.DebugLevel.INFO, on_any_level=True, ) - comm_settings = file_name.upper().split("__-__") - if len(comm_settings) == 1: - keys = comm_settings[0] - elif len(comm_settings) == 2: - keys = comm_settings[1] - elif len(comm_settings) > 2: - continue - keys = keys.split("__+__") - shortcut_keys = [] - shortcut = [] - for key in keys: - if not self.env["runtime"]["InputManager"].is_valid_key( - key.upper() - ): - self.env["runtime"]["DebugManager"].write_debug_out( - "invalid key : " - + key.upper() - + " script:" - + file_name, - debug.DebugLevel.WARNING, - ) - invalid = True - break - shortcut_keys.append(key.upper()) - if invalid: - continue - if "KEY_SCRIPT" not in shortcut_keys: - shortcut_keys.append("KEY_SCRIPT") - shortcut.append(1) - shortcut.append(sorted(shortcut_keys)) - self.env["bindings"][str(shortcut)] = file_name.upper() except Exception as e: self.env["runtime"]["DebugManager"].write_debug_out( "Loading script:" + file_name, debug.DebugLevel.ERROR @@ -266,6 +297,35 @@ class CommandManager: str(e), debug.DebugLevel.ERROR ) continue + return True + + def get_script_name(self, file_name): + return file_name.upper().split("__-__", 1)[0] + + def get_script_shortcut(self, file_name): + comm_settings = file_name.upper().split("__-__") + if len(comm_settings) == 1: + keys = comm_settings[0] + elif len(comm_settings) == 2: + keys = comm_settings[1] + else: + return None + + shortcut_keys = [] + for key in keys.split("__+__"): + key = key.upper() + if not key: + return None + if not self.env["runtime"]["InputManager"].is_valid_key(key): + self.env["runtime"]["DebugManager"].write_debug_out( + "invalid key : " + key + " script:" + file_name, + debug.DebugLevel.WARNING, + ) + return None + shortcut_keys.append(key) + if "KEY_SCRIPT" not in shortcut_keys: + shortcut_keys.append("KEY_SCRIPT") + return [1, sorted(shortcut_keys)] def shutdown_commands(self, section): # Check if the section exists in the commands dictionary diff --git a/src/fenrirscreenreader/core/outputManager.py b/src/fenrirscreenreader/core/outputManager.py index 3a4d2f44..3df2bc9a 100644 --- a/src/fenrirscreenreader/core/outputManager.py +++ b/src/fenrirscreenreader/core/outputManager.py @@ -45,7 +45,7 @@ class OutputManager: announce_capital=False, flush=True, ): - if text == "": + if text == "" and sound_icon == "": return if ( self.env["runtime"]["SettingsManager"].get_setting_as_bool( @@ -63,6 +63,8 @@ class OutputManager: "sound_icon found", debug.DebugLevel.INFO ) return + if text == "": + return if (len(text) > 1) and (text.strip(string.whitespace) == ""): return is_capital = self._should_announce_capital(text, announce_capital) @@ -349,6 +351,14 @@ class OutputManager: return False + def play_sound(self, sound_icon="", interrupt=True): + aliases = { + "ERROR": "ERRORSCREEN", + "ERRORSOUND": "ERRORSCREEN", + } + normalized_icon = aliases.get(str(sound_icon).upper(), sound_icon) + return self.play_sound_icon(normalized_icon, interrupt) + def play_frequence(self, frequence, duration, interrupt=True): if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( "sound", "enabled" diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py index f7e6c9a2..de855391 100644 --- a/src/fenrirscreenreader/core/settingsManager.py +++ b/src/fenrirscreenreader/core/settingsManager.py @@ -47,6 +47,11 @@ class SettingsManager: user_settings_file = ( "~/.local/share/stormux/fenrirscreenreader/settings/settings.conf" ) + user_resource_root = "~/.local/stormux/fenrir/" + system_sound_roots = [ + "/usr/share/sounds/fenrir/", + "/usr/share/sounds/fenrirscreenreader/", + ] def __init__(self): self.settings = settings_data @@ -134,6 +139,15 @@ class SettingsManager: def get_bundled_settings_root(self): return os.path.abspath(os.path.join(fenrir_path, "../../config/")) + "/" + def get_user_resource_root(self): + return os.path.expanduser(self.user_resource_root) + + def get_user_script_path(self): + return self.get_user_resource_root() + + def get_user_sound_root(self): + return os.path.join(self.get_user_resource_root(), "sounds/") + def get_resource_settings_root(self): if os.path.exists(self.system_settings_root): return self.system_settings_root @@ -142,6 +156,56 @@ class SettingsManager: return bundled_settings_root return "" + def get_sound_theme_roots(self): + candidate_roots = [ + self.get_user_sound_root(), + *self.system_sound_roots, + os.path.join(self.get_bundled_settings_root(), "sound/"), + ] + sound_roots = [] + seen_roots = set() + for root in candidate_roots: + normalized_root = os.path.abspath(os.path.expanduser(root)) + if normalized_root in seen_roots: + continue + sound_roots.append(normalized_root + "/") + seen_roots.add(normalized_root) + return sound_roots + + def resolve_sound_theme_path(self, theme): + if not theme: + return "" + theme = os.path.expanduser(theme) + if os.path.exists(os.path.join(theme, "soundicons.conf")): + return theme + for sound_root in self.get_sound_theme_roots(): + theme_path = os.path.join(sound_root, theme) + if os.path.exists(os.path.join(theme_path, "soundicons.conf")): + return theme_path + return "" + + def load_keyboard_layout(self, environment): + settings_root = self.get_resource_settings_root() + keyboard_layout = self.get_setting("keyboard", "keyboard_layout") + if os.path.exists(keyboard_layout): + environment["runtime"]["InputManager"].load_shortcuts( + keyboard_layout + ) + return + + layout_path = os.path.join(settings_root, "keyboard", keyboard_layout) + if os.path.exists(layout_path): + self.set_setting("keyboard", "keyboard_layout", layout_path) + environment["runtime"]["InputManager"].load_shortcuts(layout_path) + return + + layout_path = os.path.join( + settings_root, "keyboard", keyboard_layout + ".conf" + ) + if os.path.exists(layout_path): + self.set_setting("keyboard", "keyboard_layout", layout_path) + environment["runtime"]["InputManager"].load_shortcuts(layout_path) + def resolve_settings_file(self, requested_settings_file=None): if requested_settings_file and os.path.exists(requested_settings_file): return requested_settings_file @@ -300,7 +364,7 @@ class SettingsManager: try: if self.env["runtime"][driverType] is not None: - self.env["runtime"][driverType].shutdown(self.env) + self.env["runtime"][driverType].shutdown() except Exception as e: self.env["runtime"]["DebugManager"].write_debug_out( "settings_manager load_driver: Error shutting down driver: " @@ -538,14 +602,6 @@ class SettingsManager: if not settings_root: return None - sound_root = "/usr/share/sounds/fenrirscreenreader/" - if not os.path.exists(sound_root): - bundled_sound_root = os.path.join( - self.get_bundled_settings_root(), "sound/" - ) - if os.path.exists(bundled_sound_root): - sound_root = bundled_sound_root - self.save_settings_path = self.get_default_save_settings_file() settings_file = self.resolve_settings_file(cliArgs.setting) if not settings_file: @@ -599,22 +655,11 @@ class SettingsManager: "screen", "ignore_screen", ",".join(ignore_screens) ) - if not os.path.exists( - self.get_setting("sound", "theme") + "/soundicons.conf" - ): - if os.path.exists(sound_root + self.get_setting("sound", "theme")): - self.set_setting( - "sound", - "theme", - sound_root + self.get_setting("sound", "theme"), - ) - if os.path.exists( - self.get_setting("sound", "theme") + "/soundicons.conf" - ): - environment["runtime"]["SettingsManager"].load_sound_icons( - self.get_setting("sound", "theme"), environment - ) - else: + sound_theme_path = self.resolve_sound_theme_path( + self.get_setting("sound", "theme") + ) + if sound_theme_path: + self.set_setting("sound", "theme", sound_theme_path) environment["runtime"]["SettingsManager"].load_sound_icons( self.get_setting("sound", "theme"), environment ) @@ -691,6 +736,7 @@ class SettingsManager: environment["runtime"]["InputManager"] = inputManager.InputManager() environment["runtime"]["InputManager"].initialize(environment) + self.load_keyboard_layout(environment) environment["runtime"]["ScreenManager"] = screenManager.ScreenManager() environment["runtime"]["ScreenManager"].initialize(environment) @@ -711,46 +757,6 @@ class SettingsManager: ] = diffReviewManager.DiffReviewManager() environment["runtime"]["DiffReviewManager"].initialize(environment) - if not os.path.exists( - self.get_setting("keyboard", "keyboard_layout") - ): - if os.path.exists( - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout") - ): - self.set_setting( - "keyboard", - "keyboard_layout", - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout"), - ) - environment["runtime"]["InputManager"].load_shortcuts( - self.get_setting("keyboard", "keyboard_layout") - ) - if os.path.exists( - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout") - + ".conf" - ): - self.set_setting( - "keyboard", - "keyboard_layout", - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout") - + ".conf", - ) - environment["runtime"]["InputManager"].load_shortcuts( - self.get_setting("keyboard", "keyboard_layout") - ) - else: - environment["runtime"]["InputManager"].load_shortcuts( - self.get_setting("keyboard", "keyboard_layout") - ) - environment["runtime"]["CursorManager"] = cursorManager.CursorManager() environment["runtime"]["CursorManager"].initialize(environment) environment["runtime"][ diff --git a/src/fenrirscreenreader/core/settingsManager.py.bak b/src/fenrirscreenreader/core/settingsManager.py.bak deleted file mode 100644 index 8105bc46..00000000 --- a/src/fenrirscreenreader/core/settingsManager.py.bak +++ /dev/null @@ -1,742 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import inspect -import os -from configparser import ConfigParser - -from fenrirscreenreader.core import applicationManager -from fenrirscreenreader.core import attributeManager -from fenrirscreenreader.core import barrierManager -from fenrirscreenreader.core import commandManager -from fenrirscreenreader.core import cursorManager -from fenrirscreenreader.core import debug -from fenrirscreenreader.core import debugManager -from fenrirscreenreader.core import diffReviewManager -from fenrirscreenreader.core import environment -from fenrirscreenreader.core import eventManager -from fenrirscreenreader.core import helpManager -from fenrirscreenreader.core import inputManager -from fenrirscreenreader.core import memoryManager -from fenrirscreenreader.core import outputManager -from fenrirscreenreader.core import processManager -from fenrirscreenreader.core import punctuationManager -from fenrirscreenreader.core import quickMenuManager -from fenrirscreenreader.core import readAllManager -from fenrirscreenreader.core import remoteManager -from fenrirscreenreader.core import sayAllManager -from fenrirscreenreader.core import screenManager -from fenrirscreenreader.core import tableManager -from fenrirscreenreader.core import textManager -from fenrirscreenreader.core import vmenuManager -from fenrirscreenreader.core.settingsData import settings_data -from fenrirscreenreader.utils import module_utils - -currentdir = os.path.dirname( - os.path.realpath(os.path.abspath(inspect.getfile(inspect.currentframe()))) -) -fenrir_path = os.path.dirname(currentdir) - - -class SettingsManager: - def __init__(self): - self.settings = settings_data - self.settingArgDict = {} - self.bindingsBackup = None - self.settings_file = "" - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_binding_backup(self): - return self.bindingsBackup.copy() - - def load_sound_icons(self, soundIconPath, environment=None): - # Use passed environment or fall back to self.env - env = environment if environment is not None else self.env - try: - with open(soundIconPath + "/soundicons.conf", "r") as siConfig: - while True: - line = siConfig.readline() - if not line: - break - line = line.replace("\n", "") - if line.replace(" ", "") == "": - continue - if line.replace(" ", "").startswith("#"): - continue - if line.count("=") != 1: - continue - values = line.split("=") - sound_icon = values[0].upper() - values[1] = values[1].replace("'", "") - values[1] = values[1].replace('"', "") - sound_icon_file = "" - if os.path.exists(values[1]): - sound_icon_file = values[1] - else: - if not soundIconPath.endswith("/"): - soundIconPath += "/" - if os.path.exists(soundIconPath + values[1]): - sound_icon_file = soundIconPath + values[1] - env["soundIcons"][sound_icon] = sound_icon_file - env["runtime"]["DebugManager"].write_debug_out( - "SoundIcon: " + sound_icon + "." + sound_icon_file, - debug.DebugLevel.INFO, - on_any_level=True, - ) - except (IOError, OSError) as e: - env["runtime"]["DebugManager"].write_debug_out( - "load_sound_icons: failed to load sound icons from " - + soundIconPath - + ". Error: " - + str(e), - debug.DebugLevel.ERROR, - ) - - def get_settings_file(self): - return self.settings_file - - def set_settings_file(self, settings_file): - if not os.path.exists(settings_file): - return - if not os.access(settings_file, os.R_OK): - return - self.settings_file = settings_file - - def load_settings(self, setting_config_path): - if not os.path.exists(setting_config_path): - return False - if not os.access(setting_config_path, os.R_OK): - return False - self.env["settings"] = ConfigParser() - try: - self.env["settings"].read(setting_config_path) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "settings_manager load_settings: Error reading config file: " - + str(e), - debug.DebugLevel.ERROR, - ) - return False - self.set_settings_file(setting_config_path) - return True - - def save_settings(self, setting_config_path): - # set opt dict here - # save file - try: - # print('file: ',setting_config_path) - for section, settings in self.settingArgDict.items(): - for setting, value in settings.items(): - # print(section, setting, value) - self.env["settings"].set(section, setting, value) - # print('full',self.env['settings']) - - config_file = open(setting_config_path, "w") - self.env["settings"].write(config_file) - config_file.close() - os.chmod(setting_config_path, 0o644) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "save_settings: save settingsfile:" - + setting_config_path - + "failed. Error:" - + str(e), - debug.DebugLevel.ERROR, - ) - - def set_setting(self, section, setting, value): - self.set_option_arg_dict(section, setting, value) - # self.env['settings'].set(section, setting, value) - - def get_setting(self, section, setting): - value = "" - try: - value = self.settingArgDict[section][setting] - return value - except Exception as e: - pass - try: - value = self.env["settings"].get(section, setting) - except Exception as e: - value = str(self.settings[section][setting]) - return value - - def get_setting_as_int(self, section, setting): - value = 0 - try: - value = int(self.settingArgDict[section][setting]) - return value - except Exception as e: - pass - try: - value = self.env["settings"].getint(section, setting) - except Exception as e: - value = self.settings[section][setting] - return value - - def get_setting_as_float(self, section, setting): - value = 0.0 - try: - value = float(self.settingArgDict[section][setting]) - return value - except Exception as e: - pass - try: - value = self.env["settings"].getfloat(section, setting) - except Exception as e: - value = self.settings[section][setting] - return value - - def get_setting_as_bool(self, section, setting): - value = False - try: - value = self.settingArgDict[section][setting].upper() in [ - "1", - "YES", - "JA", - "TRUE", - ] - return value - except Exception as e: - pass - try: - value = self.env["settings"].getboolean(section, setting) - except Exception as e: - value = self.settings[section][setting] - return value - - def load_driver(self, driverName, driverType): - # Map runtime keys to actual directory names - driver_dir_map = { - "InputDriver": "inputDriver", - "ScreenDriver": "screenDriver", - "SpeechDriver": "speechDriver", - "SoundDriver": "soundDriver", - "RemoteDriver": "remoteDriver", - } - driver_dir = driver_dir_map.get(driverType, driverType) - - try: - if self.env["runtime"][driverType] is not None: - self.env["runtime"][driverType].shutdown(self.env) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "settings_manager load_driver: Error shutting down driver: " - + str(e), - debug.DebugLevel.ERROR, - ) - try: - driver_mod = module_utils.import_module( - driverName, - fenrir_path + "/" + driver_dir + "/" + driverName + ".py", - ) - self.env["runtime"][driverType] = driver_mod.driver() - self.env["runtime"][driverType].initialize(self.env) - self.env["runtime"]["DebugManager"].write_debug_out( - "Loading Driver " + driverType + " (" + driverName + ") OK", - debug.DebugLevel.INFO, - on_any_level=True, - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "Loading Driver " - + driverType - + " (" - + driverName - + ") FAILED:" - + str(e), - debug.DebugLevel.ERROR, - ) - try: - driver_mod = module_utils.import_module( - driverName, - fenrir_path + "/" + driver_dir + "/dummyDriver.py", - ) - self.env["runtime"][driverType] = driver_mod.driver() - self.env["runtime"][driverType].initialize(self.env) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "(fallback) Loading Driver " - + driverType - + " (dummyDriver) FAILED:" - + str(e), - debug.DebugLevel.ERROR, - ) - - def shutdown_driver(self, driverType): - try: - self.env["runtime"][driverType].shutdown() - except Exception as e: - pass - del self.env["runtime"][driverType] - - def set_fenrir_keys(self, keys): - keys = keys.upper() - key_list = keys.split(",") - for key in key_list: - if key not in self.env["input"]["fenrir_key"]: - self.env["input"]["fenrir_key"].append(key) - - def set_script_keys(self, keys): - keys = keys.upper() - key_list = keys.split(",") - for key in key_list: - if key not in self.env["input"]["script_key"]: - self.env["input"]["script_key"].append(key) - - def reset_setting_arg_dict(self): - self.settingArgDict = {} - self.env["runtime"]["OutputManager"].reset_SpeechDriver() - - def set_option_arg_dict(self, section, setting, value): - # section = section.lower() - # setting = setting.lower() - try: - e = self.settingArgDict[section] - except KeyError: - self.settingArgDict[section] = {} - try: - t = self.settings[section][setting] - except Exception as e: - print(section, setting, "not found") - return - try: - v = value # Initialize v with the original value - if isinstance(self.settings[section][setting], str): - v = str(value) - elif isinstance(self.settings[section][setting], bool): - if value not in ["True", "False"]: - raise ValueError( - "could not convert string to bool: " + value - ) - v = value == "True" - elif isinstance(self.settings[section][setting], int): - v = int(value) - elif isinstance(self.settings[section][setting], float): - v = float(value) - - # Content validation for critical settings - self._validate_setting_value(section, setting, v) - - self.settingArgDict[section][setting] = str(value) - except Exception as e: - print( - "settings_manager:set_option_arg_dict:Datatype missmatch: " - + section - + "#" - + setting - + "=" - + str(value) - + " Error:" - + str(e) - ) - # self.env['runtime']['DebugManager'].write_debug_out('settings_manager:set_option_arg_dict:Datatype - # missmatch: '+ section + '#' + setting + '=' + value + ' Error:' - # + str(e), debug.DebugLevel.ERROR) - return - - def _validate_setting_value(self, section, setting, value): - """Validate setting values for critical screen reader functionality. - Only validates settings that could cause crashes or accessibility issues. - Invalid values raise ValueError which is caught by the calling method. - """ - - # Speech settings validation - critical for accessibility - if section == "speech": - if setting == "rate": - if not (0.0 <= value <= 3.0): - raise ValueError( - f"Speech rate must be between 0.0 and 3.0, got {value}" - ) - elif setting == "pitch": - if not (0.0 <= value <= 2.0): - raise ValueError( - f"Speech pitch must be between 0.0 and 2.0, got {value}" - ) - elif setting == "volume": - if not (0.0 <= value <= 1.5): - raise ValueError( - f"Speech volume must be between 0.0 and 1.5, got {value}" - ) - elif setting == "driver": - valid_drivers = [ - "speechdDriver", - "genericDriver", - "dummyDriver", - ] - if value not in valid_drivers: - raise ValueError( - f"Invalid speech driver: {value}. Valid options: {valid_drivers}" - ) - - # Sound settings validation - elif section == "sound": - if setting == "volume": - if not (0.0 <= value <= 1.5): - raise ValueError( - f"Sound volume must be between 0.0 and 1.5, got {value}" - ) - elif setting == "driver": - valid_drivers = [ - "genericDriver", - "gstreamerDriver", - "dummyDriver", - ] - if value not in valid_drivers: - raise ValueError( - f"Invalid sound driver: {value}. Valid options: {valid_drivers}" - ) - - # Screen settings validation - elif section == "screen": - if setting == "driver": - valid_drivers = ["vcsaDriver", "ptyDriver", "dummyDriver"] - if value not in valid_drivers: - raise ValueError( - f"Invalid screen driver: {value}. Valid options: {valid_drivers}" - ) - - # Input settings validation - elif section == "keyboard": - if setting == "driver": - valid_drivers = [ - "evdevDriver", - "x11Driver", - "dummyDriver", - ] - if value not in valid_drivers: - raise ValueError( - f"Invalid input driver: {value}. Valid options: {valid_drivers}" - ) - - # General settings validation - elif section == "general": - if setting == "debug_level": - if not (0 <= value <= 3): - raise ValueError( - f"Debug level must be between 0 and 3, got {value}" - ) - - def parse_setting_args(self, settingArgs): - if settingArgs is None: - return - for optionElem in settingArgs.split(";"): - setting_val_list = [] - section_option_list = [] - section = "" - option = "" - value = "" - setting_val_list = optionElem.split("=", 1) - if len(setting_val_list) != 2: - continue - if "#" in setting_val_list[0]: - section_option_list = setting_val_list[0].split("#", 1) - elif "." in setting_val_list[0]: - section_option_list = setting_val_list[0].split(".", 1) - elif "," in setting_val_list[0]: - section_option_list = setting_val_list[0].split(",", 1) - elif "!" in setting_val_list[0]: - section_option_list = setting_val_list[0].split("!", 1) - else: - continue - if len(section_option_list) != 2: - continue - - section = str(section_option_list[0]) - option = str(section_option_list[1]) - value = str(setting_val_list[1]) - self.set_option_arg_dict(section, option, value) - - def init_fenrir_config( - self, cliArgs, fenrir_manager=None, environment=environment.environment - ): - settings_root = "/etc/fenrirscreenreader/" - settings_file = cliArgs.setting - sound_root = "/usr/share/sounds/fenrirscreenreader/" - # get fenrir settings root - if not os.path.exists(settings_root): - if os.path.exists(fenrir_path + "/../../config/"): - settings_root = fenrir_path + "/../../config/" - else: - return None - # get settings file - if settings_file is None or not os.path.exists(settings_file): - if os.path.exists(settings_root + "/settings/settings.conf"): - settings_file = settings_root + "/settings/settings.conf" - else: - return None - # get sound themes root - if not os.path.exists(sound_root): - if os.path.exists(fenrir_path + "/../../config/sound/"): - sound_root = fenrir_path + "/../../config/sound/" - - environment["runtime"]["SettingsManager"] = self - environment["runtime"]["SettingsManager"].initialize(environment) - - valid_config = environment["runtime"]["SettingsManager"].load_settings( - settings_file - ) - if not valid_config: - return None - if cliArgs.options != "": - self.parse_setting_args(cliArgs.options) - if cliArgs.debug: - self.set_setting("general", "debug_level", 3) - if cliArgs.print: - self.set_setting("general", "debug_level", 3) - self.set_setting("general", "debug_mode", "PRINT") - if cliArgs.x11: - self.set_setting("screen", "driver", "ptyDriver") - self.set_setting("keyboard", "driver", "x11Driver") - if cliArgs.x11_window_id: - self.set_setting( - "keyboard", "x11_window_id", cliArgs.x11_window_id - ) - - self.set_fenrir_keys(self.get_setting("general", "fenrir_keys")) - self.set_script_keys(self.get_setting("general", "script_keys")) - - environment["runtime"]["DebugManager"] = debugManager.DebugManager( - self.env["runtime"]["SettingsManager"].get_setting( - "general", "debug_file" - ) - ) - environment["runtime"]["DebugManager"].initialize(environment) - - if cliArgs.force_all_screens: - environment["runtime"]["force_all_screens"] = True - - if cliArgs.ignore_screen: - current_ignore_screen = self.get_setting("screen", "ignore_screen") - if current_ignore_screen: - ignore_screens = ( - current_ignore_screen.split(",") + cliArgs.ignore_screen - ) - else: - ignore_screens = cliArgs.ignore_screen - self.set_setting( - "screen", "ignore_screen", ",".join(ignore_screens) - ) - - if not os.path.exists( - self.get_setting("sound", "theme") + "/soundicons.conf" - ): - if os.path.exists(sound_root + self.get_setting("sound", "theme")): - self.set_setting( - "sound", - "theme", - sound_root + self.get_setting("sound", "theme"), - ) - if os.path.exists( - self.get_setting("sound", "theme") + "/soundicons.conf" - ): - environment["runtime"]["SettingsManager"].load_sound_icons( - self.get_setting("sound", "theme"), environment - ) - else: - environment["runtime"]["SettingsManager"].load_sound_icons( - self.get_setting("sound", "theme"), environment - ) - - environment["runtime"][ - "PunctuationManager" - ] = punctuationManager.PunctuationManager() - environment["runtime"]["PunctuationManager"].initialize(environment) - - environment["runtime"]["TextManager"] = textManager.TextManager() - environment["runtime"]["TextManager"].initialize(environment) - - if not os.path.exists( - self.get_setting("general", "punctuation_profile") - ): - if os.path.exists( - settings_root - + "punctuation/" - + self.get_setting("general", "punctuation_profile") - ): - self.set_setting( - "general", - "punctuation_profile", - settings_root - + "punctuation/" - + self.get_setting("general", "punctuation_profile"), - ) - environment["runtime"]["PunctuationManager"].load_dicts( - self.get_setting("general", "punctuation_profile") - ) - if os.path.exists( - settings_root - + "punctuation/" - + self.get_setting("general", "punctuation_profile") - + ".conf" - ): - self.set_setting( - "general", - "punctuation_profile", - settings_root - + "punctuation/" - + self.get_setting("general", "punctuation_profile") - + ".conf", - ) - environment["runtime"]["PunctuationManager"].load_dicts( - self.get_setting("general", "punctuation_profile") - ) - else: - environment["runtime"]["PunctuationManager"].load_dicts( - self.get_setting("general", "punctuation_profile") - ) - - if fenrir_manager: - environment["runtime"]["FenrirManager"] = fenrir_manager - - environment["runtime"]["MemoryManager"] = memoryManager.MemoryManager() - environment["runtime"]["MemoryManager"].initialize(environment) - - environment["runtime"][ - "AttributeManager" - ] = attributeManager.AttributeManager() - environment["runtime"]["AttributeManager"].initialize(environment) - - environment["runtime"]["EventManager"] = eventManager.EventManager() - environment["runtime"]["EventManager"].initialize(environment) - - environment["runtime"][ - "ProcessManager" - ] = processManager.ProcessManager() - environment["runtime"]["ProcessManager"].initialize(environment) - - environment["runtime"]["OutputManager"] = outputManager.OutputManager() - environment["runtime"]["OutputManager"].initialize(environment) - - environment["runtime"]["InputManager"] = inputManager.InputManager() - environment["runtime"]["InputManager"].initialize(environment) - - environment["runtime"]["ScreenManager"] = screenManager.ScreenManager() - environment["runtime"]["ScreenManager"].initialize(environment) - - environment["runtime"][ - "CommandManager" - ] = commandManager.CommandManager() - environment["runtime"]["CommandManager"].initialize(environment) - - environment["runtime"]["HelpManager"] = helpManager.HelpManager() - environment["runtime"]["HelpManager"].initialize(environment) - - environment["runtime"]["RemoteManager"] = remoteManager.RemoteManager() - environment["runtime"]["RemoteManager"].initialize(environment) - - environment["runtime"][ - "DiffReviewManager" - ] = diffReviewManager.DiffReviewManager() - environment["runtime"]["DiffReviewManager"].initialize(environment) - - if not os.path.exists( - self.get_setting("keyboard", "keyboard_layout") - ): - if os.path.exists( - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout") - ): - self.set_setting( - "keyboard", - "keyboard_layout", - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout"), - ) - environment["runtime"]["InputManager"].load_shortcuts( - self.get_setting("keyboard", "keyboard_layout") - ) - if os.path.exists( - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout") - + ".conf" - ): - self.set_setting( - "keyboard", - "keyboard_layout", - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout") - + ".conf", - ) - environment["runtime"]["InputManager"].load_shortcuts( - self.get_setting("keyboard", "keyboard_layout") - ) - else: - environment["runtime"]["InputManager"].load_shortcuts( - self.get_setting("keyboard", "keyboard_layout") - ) - - environment["runtime"]["CursorManager"] = cursorManager.CursorManager() - environment["runtime"]["CursorManager"].initialize(environment) - environment["runtime"][ - "ApplicationManager" - ] = applicationManager.ApplicationManager() - environment["runtime"]["ApplicationManager"].initialize(environment) - environment["runtime"]["TextManager"] = textManager.TextManager() - environment["runtime"]["TextManager"].initialize(environment) - environment["runtime"]["TableManager"] = tableManager.TableManager() - environment["runtime"]["TableManager"].initialize(environment) - environment["runtime"][ - "BarrierManager" - ] = barrierManager.BarrierManager() - environment["runtime"]["BarrierManager"].initialize(environment) - environment["runtime"]["SayAllManager"] = sayAllManager.SayAllManager() - environment["runtime"]["SayAllManager"].initialize(environment) - environment["runtime"]["VmenuManager"] = vmenuManager.VmenuManager() - environment["runtime"]["VmenuManager"].initialize(environment) - environment["runtime"][ - "QuickMenuManager" - ] = quickMenuManager.QuickMenuManager() - environment["runtime"]["QuickMenuManager"].initialize(environment) - - environment["runtime"][ - "ReadAllManager" - ] = readAllManager.ReadAllManager() - environment["runtime"]["ReadAllManager"].initialize(environment) - - # only possible after having input and screen managers with clean - # buffer - environment["runtime"]["InputManager"].write_event_buffer() - environment["runtime"]["InputManager"].handle_device_grab(force=True) - - environment["runtime"]["DebugManager"].write_debug_out( - r"/-------environment-------/", - debug.DebugLevel.INFO, - on_any_level=True, - ) - environment["runtime"]["DebugManager"].write_debug_out( - str(environment), debug.DebugLevel.INFO, on_any_level=True - ) - environment["runtime"]["DebugManager"].write_debug_out( - r"/-------settings.conf-------/", - debug.DebugLevel.INFO, - on_any_level=True, - ) - environment["runtime"]["DebugManager"].write_debug_out( - str(environment["settings"]._sections), - debug.DebugLevel.INFO, - on_any_level=True, - ) - environment["runtime"]["DebugManager"].write_debug_out( - r"/-------self.settingArgDict-------/", - debug.DebugLevel.INFO, - on_any_level=True, - ) - environment["runtime"]["DebugManager"].write_debug_out( - str(self.settingArgDict), debug.DebugLevel.INFO, on_any_level=True - ) - self.bindingsBackup = environment["bindings"].copy() - - return environment diff --git a/src/fenrirscreenreader/core/soundDriver.py b/src/fenrirscreenreader/core/soundDriver.py index fb0c2036..07678059 100644 --- a/src/fenrirscreenreader/core/soundDriver.py +++ b/src/fenrirscreenreader/core/soundDriver.py @@ -20,7 +20,7 @@ class sound_driver: if not self._initialized: return self.cancel() - self._is_initialized = False + self._initialized = False def play_frequence( self, frequence, duration, adjust_volume=0.0, interrupt=True diff --git a/src/fenrirscreenreader/screenDriver/ptyDriver.py b/src/fenrirscreenreader/screenDriver/ptyDriver.py index 96575c9e..54c914d1 100644 --- a/src/fenrirscreenreader/screenDriver/ptyDriver.py +++ b/src/fenrirscreenreader/screenDriver/ptyDriver.py @@ -202,6 +202,9 @@ class driver(screenDriver): self.terminal = None self.p_pid = -1 self.terminal_lock = threading.Lock() # Synchronize terminal operations + self.stdin_interrupt_lock = threading.Lock() + self.stdin_interrupt_running = False + self.stdin_interrupt_thread = None signal.signal(signal.SIGWINCH, self.handle_sigwinch) # Runtime configuration storage @@ -301,7 +304,31 @@ class driver(screenDriver): "keyboard", "interrupt_on_key_press_filter" ).strip(): return - self.env["runtime"]["OutputManager"].interrupt_output() + self.start_stdin_interrupt_thread() + + def start_stdin_interrupt_thread(self): + with self.stdin_interrupt_lock: + if self.stdin_interrupt_running: + return + self.stdin_interrupt_running = True + self.stdin_interrupt_thread = threading.Thread( + target=self.run_stdin_interrupt, + daemon=True, + ) + self.stdin_interrupt_thread.start() + + def run_stdin_interrupt(self): + try: + self.env["runtime"]["OutputManager"].interrupt_output() + except Exception as e: + self.env["runtime"]["DebugManager"].write_debug_out( + "ptyDriver interrupt_output_on_stdin_input: " + + str(e), + debug.DebugLevel.ERROR, + ) + finally: + with self.stdin_interrupt_lock: + self.stdin_interrupt_running = False def handle_stdin_input(self, msg_bytes, event_queue): if self.synthesize_backspace_shortcut(msg_bytes, event_queue): diff --git a/src/fenrirscreenreader/soundDriver/gstreamerDriver.py b/src/fenrirscreenreader/soundDriver/gstreamerDriver.py index 27e3064c..7fb88c8a 100644 --- a/src/fenrirscreenreader/soundDriver/gstreamerDriver.py +++ b/src/fenrirscreenreader/soundDriver/gstreamerDriver.py @@ -36,7 +36,7 @@ class driver(sound_driver): self._initialized = _gstreamerAvailable if not self._initialized: global _availableError - self.environment["runtime"]["DebugManager"].write_debug_out( + self.env["runtime"]["DebugManager"].write_debug_out( "Gstreamer not available " + _availableError, debug.DebugLevel.ERROR, ) diff --git a/tests/unit/test_local_resources.py b/tests/unit/test_local_resources.py new file mode 100644 index 00000000..21086f92 --- /dev/null +++ b/tests/unit/test_local_resources.py @@ -0,0 +1,122 @@ +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.core.commandManager import CommandManager +from fenrirscreenreader.core.settingsManager import SettingsManager + + +def write_script(path): + path.write_text("#!/bin/sh\n", encoding="utf-8") + path.chmod(0o755) + + +def build_command_environment(local_path, system_path): + settings_manager = Mock() + settings_manager.get_user_script_path.return_value = str(local_path) + settings_manager.get_setting.return_value = str(system_path) + valid_keys = { + "KEY_A", + "KEY_B", + "KEY_C", + "KEY_D", + "KEY_E", + "KEY_SCRIPT", + } + + return { + "commands": {"commands": {}}, + "commandsIgnore": {"commands": {}}, + "bindings": { + str([1, sorted(["KEY_C", "KEY_SCRIPT"])]): "EXISTING" + }, + "rawBindings": {}, + "general": {"curr_user": "Username"}, + "runtime": { + "SettingsManager": settings_manager, + "DebugManager": Mock(write_debug_out=Mock()), + "InputManager": Mock( + is_valid_key=Mock(side_effect=lambda key: key in valid_keys) + ), + }, + } + + +@pytest.mark.unit +def test_local_scripts_load_before_non_conflicting_system_scripts(tmp_path): + local_path = tmp_path / "local" + system_path = tmp_path / "system" + local_path.mkdir() + system_path.mkdir() + write_script(local_path / "local_only__-__KEY_A") + write_script(local_path / "same_name__-__KEY_B") + write_script(system_path / "same_name__-__KEY_D") + write_script(system_path / "system_only__-__KEY_E") + + env = build_command_environment(local_path, system_path) + command_manager = CommandManager() + command_manager.initialize = lambda environment: None + command_manager.env = env + + command_manager.load_script_commands() + + commands = env["commands"]["commands"] + assert "LOCAL_ONLY__-__KEY_A" in commands + assert "SYSTEM_ONLY__-__KEY_E" in commands + assert commands["SAME_NAME__-__KEY_B"].script_path == str( + local_path / "same_name__-__KEY_B" + ) + assert "SAME_NAME__-__KEY_D" not in commands + + +@pytest.mark.unit +def test_system_scripts_skip_existing_shortcut_bindings(tmp_path): + local_path = tmp_path / "local" + system_path = tmp_path / "system" + local_path.mkdir() + system_path.mkdir() + write_script(system_path / "conflicting_key__-__KEY_C") + write_script(system_path / "system_only__-__KEY_E") + + env = build_command_environment(local_path, system_path) + command_manager = CommandManager() + command_manager.env = env + + command_manager.load_script_commands() + + commands = env["commands"]["commands"] + assert "CONFLICTING_KEY__-__KEY_C" not in commands + assert "SYSTEM_ONLY__-__KEY_E" in commands + + +@pytest.mark.unit +def test_sound_theme_resolution_prefers_local_soundpacks(tmp_path): + manager = SettingsManager() + local_root = tmp_path / "local" / "fenrir" + system_root = tmp_path / "system" / "sounds" / "fenrir" + local_theme = local_root / "sounds" / "default" + system_theme = system_root / "default" + local_theme.mkdir(parents=True) + system_theme.mkdir(parents=True) + (local_theme / "soundicons.conf").write_text( + "Accept=Accept.wav\n", encoding="utf-8" + ) + (system_theme / "soundicons.conf").write_text( + "Accept=SystemAccept.wav\n", encoding="utf-8" + ) + manager.user_resource_root = str(local_root) + manager.system_sound_roots = [str(system_root)] + + assert manager.resolve_sound_theme_path("default") == str(local_theme) + + +@pytest.mark.unit +def test_sound_theme_resolution_accepts_absolute_theme_path(tmp_path): + manager = SettingsManager() + theme_path = tmp_path / "custom" + theme_path.mkdir() + (theme_path / "soundicons.conf").write_text( + "Accept=Accept.wav\n", encoding="utf-8" + ) + + assert manager.resolve_sound_theme_path(str(theme_path)) == str(theme_path) diff --git a/tests/unit/test_output_manager.py b/tests/unit/test_output_manager.py new file mode 100644 index 00000000..a540bd08 --- /dev/null +++ b/tests/unit/test_output_manager.py @@ -0,0 +1,48 @@ +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.core.outputManager import OutputManager + + +def build_output_manager(): + settings_manager = Mock() + settings_manager.get_setting_as_bool.return_value = True + settings_manager.get_setting_as_float.return_value = 1.0 + sound_driver = Mock() + output_manager = OutputManager() + output_manager.env = { + "soundIcons": { + "ACCEPT": "/tmp/Accept.wav", + "ERRORSCREEN": "/tmp/ErrorScreen.wav", + }, + "runtime": { + "SettingsManager": settings_manager, + "SoundDriver": sound_driver, + "SpeechDriver": Mock(), + "DebugManager": Mock(write_debug_out=Mock()), + }, + } + return output_manager, sound_driver + + +@pytest.mark.unit +def test_present_text_allows_sound_only_feedback(): + output_manager, sound_driver = build_output_manager() + + output_manager.present_text("", sound_icon="Accept", interrupt=False) + + sound_driver.play_sound_file.assert_called_once_with( + "/tmp/Accept.wav", False + ) + + +@pytest.mark.unit +def test_play_sound_supports_error_alias(): + output_manager, sound_driver = build_output_manager() + + assert output_manager.play_sound("Error") is True + + sound_driver.play_sound_file.assert_called_once_with( + "/tmp/ErrorScreen.wav", True + ) diff --git a/tests/unit/test_pty_terminal_sequences.py b/tests/unit/test_pty_terminal_sequences.py index a4522904..6e1cca3c 100644 --- a/tests/unit/test_pty_terminal_sequences.py +++ b/tests/unit/test_pty_terminal_sequences.py @@ -1,6 +1,9 @@ -import pytest +import threading +import time from unittest.mock import Mock +import pytest + from fenrirscreenreader.core.eventData import FenrirEventType from fenrirscreenreader.screenDriver.ptyDriver import PTYConstants from fenrirscreenreader.screenDriver.ptyDriver import Terminal @@ -71,10 +74,48 @@ def test_pty_stdin_input_interrupts_output_when_all_keys_interrupt_enabled(): } pty_driver.interrupt_output_on_stdin_input(b"a") + pty_driver.stdin_interrupt_thread.join(timeout=1.0) output_manager.interrupt_output.assert_called_once_with() +@pytest.mark.unit +def test_pty_stdin_input_interrupt_does_not_block_input_injection(): + pty_driver = PtyDriver() + settings_manager = Mock() + settings_manager.get_setting_as_bool.return_value = True + settings_manager.get_setting.return_value = "" + interrupt_started = threading.Event() + release_interrupt = threading.Event() + + def slow_interrupt(): + interrupt_started.set() + release_interrupt.wait(timeout=1.0) + + output_manager = Mock(interrupt_output=Mock(side_effect=slow_interrupt)) + pty_driver.env = { + "input": {"curr_input": []}, + "runtime": { + "SettingsManager": settings_manager, + "OutputManager": output_manager, + "DebugManager": Mock(write_debug_out=Mock()), + }, + } + pty_driver.inject_text_to_screen = Mock() + + start_time = time.monotonic() + pty_driver.handle_stdin_input(b"a", Mock()) + elapsed = time.monotonic() - start_time + + try: + assert interrupt_started.wait(timeout=0.2) + assert elapsed < 0.2 + pty_driver.inject_text_to_screen.assert_called_once_with(b"a") + finally: + release_interrupt.set() + pty_driver.stdin_interrupt_thread.join(timeout=1.0) + + @pytest.mark.unit def test_pty_stdin_input_honors_interrupt_disabled(): pty_driver = PtyDriver() diff --git a/tests/unit/test_sound_drivers.py b/tests/unit/test_sound_drivers.py new file mode 100644 index 00000000..67d9c72d --- /dev/null +++ b/tests/unit/test_sound_drivers.py @@ -0,0 +1,29 @@ +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.core.soundDriver import sound_driver +from fenrirscreenreader.soundDriver import gstreamerDriver + + +@pytest.mark.unit +def test_base_sound_driver_shutdown_clears_initialized_flag(): + driver = sound_driver() + driver.initialize({}) + + driver.shutdown() + + assert driver._initialized is False + + +@pytest.mark.unit +def test_gstreamer_driver_unavailable_logs_without_crashing(monkeypatch): + monkeypatch.setattr(gstreamerDriver, "_gstreamerAvailable", False) + monkeypatch.setattr(gstreamerDriver, "_availableError", "missing", raising=False) + debug_manager = Mock(write_debug_out=Mock()) + driver = gstreamerDriver.driver() + + driver.initialize({"runtime": {"DebugManager": debug_manager}}) + + debug_manager.write_debug_out.assert_called() + assert driver._initialized is False diff --git a/tests/unit/test_subprocess_command.py b/tests/unit/test_subprocess_command.py new file mode 100644 index 00000000..777c3064 --- /dev/null +++ b/tests/unit/test_subprocess_command.py @@ -0,0 +1,33 @@ +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.commands.commands import subprocess as subprocess_command + + +@pytest.mark.unit +def test_script_command_executes_without_shell(monkeypatch): + process = Mock() + process.communicate.return_value = (b"done", b"") + popen = Mock(return_value=process) + monkeypatch.setattr(subprocess_command, "Popen", popen) + output_manager = Mock() + command = subprocess_command.command() + command.initialize( + { + "general": {"curr_user": "Username"}, + "runtime": {"OutputManager": output_manager}, + }, + "/tmp/script with spaces", + ) + + command._thread_run() + + popen.assert_called_once_with( + ["/tmp/script with spaces", "Username"], + stdout=subprocess_command.PIPE, + stderr=subprocess_command.PIPE, + ) + output_manager.present_text.assert_called_once_with( + "done", sound_icon="", interrupt=False + )