Files
cthulhu/tests/cthulhu_test_context.py

327 lines
15 KiB
Python

# 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 importlib
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."""
self._import_patch_target_parent(target)
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)
@staticmethod
def _import_patch_target_parent(target: str) -> None:
"""Import the deepest module prefix for string patch targets."""
parts = target.split(".")
for index in range(len(parts) - 1, 0, -1):
module_name = ".".join(parts[:index])
try:
module = importlib.import_module(module_name)
except ImportError:
continue
if "." in module_name:
package_name, attr_name = module_name.rsplit(".", 1)
package = sys.modules.get(package_name)
if package is not None:
setattr(package, attr_name, module)
break
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()