diff --git a/meson.build b/meson.build index 23f79e9..0cce3df 100644 --- a/meson.build +++ b/meson.build @@ -6,15 +6,15 @@ project('cthulhu', python = import('python') i18n = import('i18n') -python_minimum_version = '3.9' +python_minimum_version = '3.10' python3 = python.find_installation('python3', required: true) if not python3.language_version().version_compare(f'>= @python_minimum_version@') error(f'Python @python_minimum_version@ or newer is required.') endif # Hard dependencies (checked via pkg-config) -dependency('atspi-2', version: '>= 2.52.0') -dependency('atk-bridge-2.0', version: '>= 2.26.0') +dependency('atspi-2', version: '>= 2.56.0') +dependency('atk-bridge-2.0', version: '>= 2.56.0') dependency('pygobject-3.0', version: '>= 3.18') # Hard Python module dependencies @@ -33,14 +33,19 @@ if not pluggy_result.found() error('pluggy module is required') endif +dasbus_result = python.find_installation('python3', modules:['dasbus'], required: false) +if not dasbus_result.found() + error('dasbus is required for D-Bus remote controller interface') +endif + # End users might not have the Gtk development libraries installed, making pkg-config fail. # Therefore, check this dependency via python. gtk_major_version = '3' -gtk_minor_version = '0' +gtk_minor_version = '24' gtk_command = ' '.join([ 'import gi; gi.require_version("Gtk", "3.0"); from gi.repository import Gtk;', 'print(f"{Gtk.get_major_version()}.{Gtk.get_minor_version()}.{Gtk.get_micro_version()}");', - f'failed = Gtk.get_major_version() != @gtk_major_version@;', + f'failed = Gtk.get_major_version() != @gtk_major_version@ or Gtk.get_minor_version() < @gtk_minor_version@;', 'exit(failed)' ]) gtk_test = run_command(python3, '-c', gtk_command, check: false) diff --git a/pyproject.toml b/pyproject.toml index 2e31090..7643916 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,13 @@ name = "cthulhu" dynamic = ["version"] description = "Fork of the Orca screen reader" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = { text = "LGPL-2.1-or-later" } dependencies = [ "pygobject>=3.18", "pluggy", "tomlkit", + "dasbus", "brlapi; extra == 'braille'", "python-speechd; extra == 'speech'", "piper-tts; extra == 'piper'", diff --git a/tests/conftest.py b/tests/conftest.py index eada025..790b160 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,12 @@ import importlib.util +import os import sys import types from pathlib import Path +import pytest + +os.environ["GSETTINGS_BACKEND"] = "memory" REPO_ROOT = Path(__file__).resolve().parents[1] SRC_ROOT = REPO_ROOT / "src" @@ -63,3 +67,27 @@ for generated_module in ("cthulhu_i18n", "cthulhu_platform"): loaded_module = _load_generated_module(generated_module) sys.modules[f"cthulhu.{generated_module}"] = loaded_module setattr(cthulhu, generated_module, loaded_module) + + +from cthulhu_test_fixtures import test_context # noqa: E402,F401 + + +def clean_all_cthulhu_modules() -> None: + modules_to_remove = [ + module_name + for module_name in sys.modules + if module_name.startswith("cthulhu.") + and module_name not in {"cthulhu.cthulhu_i18n", "cthulhu.cthulhu_platform"} + ] + for module_name in modules_to_remove: + sys.modules.pop(module_name, None) + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line("markers", "unit: marks tests as unit tests") + + +def pytest_runtest_setup(item: pytest.Item) -> None: + clean_all_cthulhu_modules() + if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) diff --git a/tests/cthulhu_test_context.py b/tests/cthulhu_test_context.py new file mode 100644 index 0000000..8ca1167 --- /dev/null +++ b/tests/cthulhu_test_context.py @@ -0,0 +1,306 @@ +# Orca Test Context - Test Isolation Framework +# +# Copyright 2025 Igalia, S.L. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +"""Test isolation framework for Cthulhu screen reader tests.""" + +from __future__ import annotations + +import os +import sys +from typing import TYPE_CHECKING, Any + +import gi + +gi.require_version("Atspi", "2.0") +gi.require_version("Gio", "2.0") +from gi.repository import Atspi, Gio, GLib # pylint: disable=wrong-import-position + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from _pytest.monkeypatch import MonkeyPatch + from pytest_mock import MockerFixture + + +class CthulhuTestContext: + """Test isolation framework for Cthulhu tests.""" + + def __init__(self, mocker: MockerFixture, monkeypatch: MonkeyPatch): + """Initialize the test context.""" + + self.mocker: MockerFixture = mocker + self.monkeypatch: MonkeyPatch = monkeypatch + self.patches: dict[str, Any] = {} + self.mocks: dict[str, MagicMock] = {} + + def patch(self, target: str, **kwargs) -> MagicMock: + """Convenience method for creating patches.""" + + return self.mocker.patch(target, **kwargs) + + def patch_object(self, target: object, attribute: str, **kwargs) -> MagicMock: + """Convenience method for patching object attributes.""" + + return self.mocker.patch.object(target, attribute, **kwargs) + + def Mock(self, **kwargs) -> MagicMock: # pylint: disable=invalid-name + """Convenience method for creating Mock objects.""" + + return self.mocker.Mock(**kwargs) + + def patch_env( + self, + env_vars: dict[str, str], + remove_vars: list[str] | None = None, + ) -> MagicMock | None: + """Convenience method for patching environment variables.""" + + if remove_vars: + for var in remove_vars: + if var in os.environ: + del os.environ[var] + + if env_vars: + return self.mocker.patch.dict(os.environ, env_vars) + return None + + def patch_module(self, module_name: str, mock_module: Any) -> MagicMock: + """Convenience method for patching sys.modules entries.""" + + return self.mocker.patch.dict(sys.modules, {module_name: mock_module}) + + def patch_modules(self, modules: dict[str, Any]) -> MagicMock: + """Convenience method for patching multiple sys.modules entries.""" + + return self.mocker.patch.dict(sys.modules, modules) + + def _setup_required_imports(self) -> None: + """Sets up commonly required module imports that real modules need.""" + + required_modules = [ + "cthulhu.cthulhu_i18n", + "cthulhu.cmdnames", + "cthulhu.input_event", + "cthulhu.keybindings", + "cthulhu.messages", + "cthulhu.text_attribute_names", + ] + + for module_name in required_modules: + if module_name not in self.mocks: + mock_module = self.mocker.patch(module_name, create=True) + if module_name == "cthulhu.cthulhu_i18n": + mock_module._ = lambda x: x + self.mocks[module_name] = mock_module + + def _setup_essential_modules(self, module_names: list[str]) -> dict[str, MagicMock]: + """Returns dictionary mapping module names to mock objects.""" + + essential_modules = {} + for module_name in module_names: + mock_module = self.mocker.Mock() + self.patch_module(module_name, mock_module) + essential_modules[module_name] = mock_module + return essential_modules + + def setup_shared_dependencies( + self, + additional_modules: list[str] | None = None, + ) -> dict[str, MagicMock]: + """Returns common/shared dependencies used across most Orca test modules.""" + + core_modules = [ + "cthulhu.debug", + "cthulhu.messages", + "cthulhu.input_event", + "cthulhu.keybindings", + "cthulhu.cmdnames", + "cthulhu.ax_object", + "cthulhu.dbus_service", + "cthulhu.script_manager", + "cthulhu.cthulhu_i18n", + "cthulhu.guilabels", + "cthulhu.text_attribute_names", + "cthulhu.focus_manager", + "cthulhu.braille", + "cthulhu.cthulhu_platform", + ] + + if additional_modules: + core_modules.extend(additional_modules) + + essential_modules = self._setup_essential_modules(core_modules) + self.configure_shared_module_behaviors(essential_modules) + return essential_modules + + # pylint: disable-next=too-many-locals, too-many-statements, too-many-branches + def configure_shared_module_behaviors(self, essential_modules: dict[str, MagicMock]) -> None: + """Configure standard behaviors for shared modules to reduce duplication.""" + + if "cthulhu.cthulhu_i18n" in essential_modules: + i18n_mock = essential_modules["cthulhu.cthulhu_i18n"] + i18n_mock._ = lambda x: x + i18n_mock.C_ = lambda c, x: x + i18n_mock.ngettext = lambda s, p, n: s if n == 1 else p + + if "cthulhu.debug" in essential_modules: + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.LEVEL_INFO = 800 + debug_mock.LEVEL_SEVERE = 1000 + debug_mock.print_message = self.mocker.Mock() + debug_mock.print_tokens = self.mocker.Mock() + debug_mock.println = self.mocker.Mock() + + if "cthulhu.keybindings" in essential_modules: + keybindings_mock = essential_modules["cthulhu.keybindings"] + bindings_instance = self.mocker.Mock() + bindings_instance.is_empty = self.mocker.Mock(return_value=True) + bindings_instance.add = self.mocker.Mock() + keybindings_mock.KeyBindings = self.mocker.Mock(return_value=bindings_instance) + keybindings_mock.KeyBinding = self.mocker.Mock(return_value=self.mocker.Mock()) + keybindings_mock.DEFAULT_MODIFIER_MASK = 1 + keybindings_mock.CTHULHU_SHIFT_MODIFIER_MASK = 2 + + if "cthulhu.focus_manager" in essential_modules: + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + manager_instance = self.mocker.Mock() + manager_instance.get_locus_of_focus = self.mocker.Mock(return_value=None) + manager_instance.set_locus_of_focus = self.mocker.Mock() + manager_instance.in_say_all = self.mocker.Mock(return_value=False) + manager_instance.is_in_preferences_window = self.mocker.Mock(return_value=False) + manager_instance.get_active_mode_and_object_of_interest = self.mocker.Mock( + return_value=(None, None), + ) + focus_manager_mock.get_manager = self.mocker.Mock(return_value=manager_instance) + focus_manager_mock.OBJECT_NAVIGATOR = "object-navigator" + essential_modules["focus_manager_instance"] = manager_instance + + if "cthulhu.dbus_service" in essential_modules: + dbus_service_mock = essential_modules["cthulhu.dbus_service"] + controller_mock = self.mocker.Mock() + controller_mock.register_decorated_module = self.mocker.Mock() + dbus_service_mock.get_remote_controller = self.mocker.Mock(return_value=controller_mock) + dbus_service_mock.command = lambda func: func + dbus_service_mock.getter = lambda func: func + dbus_service_mock.setter = lambda func: func + dbus_service_mock.parameterized_command = lambda func: func + + if "cthulhu.script_manager" in essential_modules: + script_manager_mock = essential_modules["cthulhu.script_manager"] + manager_instance = self.mocker.Mock() + script_instance = self.mocker.Mock() + script_instance.present_message = self.mocker.Mock() + script_instance.present_object = self.mocker.Mock() + script_instance.speak_message = self.mocker.Mock() + script_instance.update_braille = self.mocker.Mock() + speech_gen = self.mocker.Mock() + braille_gen = self.mocker.Mock() + script_instance.get_speech_generator = self.mocker.Mock(return_value=speech_gen) + script_instance.get_braille_generator = self.mocker.Mock(return_value=braille_gen) + manager_instance.get_active_script = self.mocker.Mock(return_value=script_instance) + manager_instance.get_script = self.mocker.Mock(return_value=script_instance) + manager_instance.get_manager = self.mocker.Mock(return_value=manager_instance) + script_manager_mock.get_manager = self.mocker.Mock(return_value=manager_instance) + + if "cthulhu.ax_object" in essential_modules: + ax_object_mock = essential_modules["cthulhu.ax_object"] + ax_object_class_mock = self.mocker.Mock() + ax_object_class_mock.is_valid = self.mocker.Mock(return_value=True) + ax_object_class_mock.is_dead = self.mocker.Mock(return_value=False) + ax_object_class_mock.get_name = self.mocker.Mock(return_value="") + ax_object_class_mock.get_role = self.mocker.Mock(return_value=Atspi.Role.PANEL) + ax_object_class_mock.get_parent = self.mocker.Mock(return_value=None) + ax_object_class_mock.find_ancestor = self.mocker.Mock(return_value=None) + ax_object_class_mock.clear_cache = self.mocker.Mock() + ax_object_mock.AXObject = ax_object_class_mock + + if "cthulhu.ax_utilities" in essential_modules: + ax_utilities_mock = essential_modules["cthulhu.ax_utilities"] + ax_utilities_class_mock = self.mocker.Mock() + ax_utilities_class_mock.is_focused = self.mocker.Mock(return_value=True) + ax_utilities_class_mock.is_table_cell_or_header = self.mocker.Mock(return_value=False) + ax_utilities_class_mock.is_list_item = self.mocker.Mock(return_value=False) + ax_utilities_class_mock.is_layout_only = self.mocker.Mock(return_value=False) + ax_utilities_class_mock.get_status_bar = self.mocker.Mock(return_value=None) + ax_utilities_class_mock.get_info_bar = self.mocker.Mock(return_value=None) + ax_utilities_class_mock.is_showing = self.mocker.Mock(return_value=True) + ax_utilities_class_mock.is_visible = self.mocker.Mock(return_value=True) + ax_utilities_class_mock.is_sensitive = self.mocker.Mock(return_value=True) + ax_utilities_mock.AXUtilities = ax_utilities_class_mock + + if "cthulhu.input_event" in essential_modules: + input_event_mock = essential_modules["cthulhu.input_event"] + input_event_mock.KEY_PRESSED_EVENT = "key-pressed" + input_event_mock.KEY_RELEASED_EVENT = "key-released" + input_event_mock.MOUSE_BUTTON_CLICKED_EVENT = "mouse-clicked" + + if "cthulhu.cthulhu_platform" in essential_modules: + cthulhu_platform_mock = essential_modules["cthulhu.cthulhu_platform"] + cthulhu_platform_mock.tablesdir = "/usr/share/liblouis/tables" + + if "cthulhu.braille_presenter" in essential_modules: + braille_presenter_mock = essential_modules["cthulhu.braille_presenter"] + presenter_instance = self.mocker.Mock() + presenter_instance.use_braille = self.mocker.Mock(return_value=False) + presenter_instance.get_braille_is_enabled = self.mocker.Mock(return_value=False) + presenter_instance.get_flash_messages_are_enabled = self.mocker.Mock(return_value=False) + presenter_instance.get_flash_messages_are_detailed = self.mocker.Mock( + return_value=False, + ) + presenter_instance.get_flashtime_from_settings = self.mocker.Mock(return_value=5000) + braille_presenter_mock.get_presenter = self.mocker.Mock(return_value=presenter_instance) + + if "cthulhu.presentation_manager" in essential_modules: + presentation_manager_mock = essential_modules["cthulhu.presentation_manager"] + manager_instance = self.mocker.Mock() + manager_instance.interrupt_presentation = self.mocker.Mock() + manager_instance.present_message = self.mocker.Mock() + manager_instance.speak_message = self.mocker.Mock() + manager_instance.speak_character = self.mocker.Mock() + manager_instance.present_braille_message = self.mocker.Mock() + manager_instance.spell_item = self.mocker.Mock() + manager_instance.spell_phonetically = self.mocker.Mock() + manager_instance.play_sound = self.mocker.Mock() + manager_instance.speak_contents = self.mocker.Mock() + manager_instance.display_contents = self.mocker.Mock() + presentation_manager_mock.get_manager = self.mocker.Mock(return_value=manager_instance) + + if "gi.repository" in essential_modules: + gi_repo_mock = essential_modules["gi.repository"] + gi_repo_mock.Gio = Gio + gi_repo_mock.GLib = GLib + + def get_mock(self, name: str) -> MagicMock | None: + """Returns mock object if it exists, None otherwise.""" + + return self.mocks.get(name) + + def __enter__(self) -> CthulhuTestContext: # noqa: PYI034 + """Enter the test context.""" + + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Exit the test context and clean up all patches.""" + + for patch_obj in self.patches.values(): + patch_obj.stop() + + self.patches.clear() + self.mocks.clear() diff --git a/tests/cthulhu_test_fixtures.py b/tests/cthulhu_test_fixtures.py new file mode 100644 index 0000000..a94248c --- /dev/null +++ b/tests/cthulhu_test_fixtures.py @@ -0,0 +1,85 @@ +# Orca Test Fixtures - Pytest Integration +# +# Copyright 2025 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +"""Pytest fixtures for Cthulhu screen reader tests. + +This module provides pytest fixtures that integrate the CthulhuTestContext +with pytest's fixture system, providing clean, isolated test environments. + +The fixtures are designed to be simple to use while providing complete +test isolation and preventing cross-test contamination. + +Usage: + def test_presenter(orca_test): + cthulhu_test.setup_where_am_i_presenter_dependencies() + from cthulhu.where_am_i_presenter import WhereAmIPresenter + + presenter = WhereAmIPresenter() + result = presenter.some_method() + assert result is True +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from cthulhu_test_context import CthulhuTestContext + +if TYPE_CHECKING: + from collections.abc import Generator + + from pytest_mock import MockerFixture + + +@pytest.fixture +def test_context(mocker: MockerFixture, monkeypatch) -> Generator[CthulhuTestContext, None, None]: + """Provides clean, isolated Orca test environment. + + This is the primary fixture for Cthulhu tests. It provides a complete + test isolation context that prevents cross-test contamination. + + Usage: + class TestMyModule: + def _setup_dependencies(self, cthulhu_test): + # Set up only what your module needs + return cthulhu_test.setup_shared_dependencies(["cthulhu.debug"]) + + def test_my_functionality(self, cthulhu_test): + self._setup_dependencies(orca_test) + from cthulhu.my_module import MyClass + + # Your test code here + instance = MyClass() + result = instance.some_method() + + assert result is True + + Args: + mocker: pytest-mock mocker fixture (automatically injected) + monkeypatch: pytest monkeypatch fixture (automatically injected) + + Yields: + CthulhuTestContext instance with clean isolation + """ + + with CthulhuTestContext(mocker, monkeypatch) as context: + yield context