import sys import types import unittest from contextlib import ExitStack from pathlib import Path from unittest import mock import gi gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.modules.setdefault( "cthulhu.cthulhu_i18n", types.SimpleNamespace( C_=lambda _context, message: message, _=lambda message: message, ngettext=lambda singular, plural, count: singular if count == 1 else plural, ), ) sys.modules.setdefault( "cthulhu.cthulhu_platform", types.SimpleNamespace( version="test", revision="", tablesdir="", datadir="", ), ) from cthulhu.script_utilities import Utilities class FakeDocument: def __init__(self): self.currentSelections = [] self.setCalls = [] class FakeTextObject: def __init__(self, name, text, document): self.name = name self.text = text self.document = document self.next_obj = None self.prev_obj = None self.caret_offset = 0 def __repr__(self): return f"" class DocumentSelectionRegressionTests(unittest.TestCase): def setUp(self): self.document = FakeDocument() self.first = FakeTextObject("first", "alpha", self.document) self.second = FakeTextObject("second", "beta", self.document) self.first.next_obj = self.second self.second.prev_obj = self.first self.script = types.SimpleNamespace( pointOfReference={}, sayPhrase=mock.Mock(), speakMessage=mock.Mock(), ) self.utility = object.__new__(Utilities) self.utility._script = self.script self.utility._clipboardHandlerId = None self.utility.expandEOCs = lambda obj, start=0, end=-1: obj.text[start:len(obj.text) if end == -1 else end] self.utility.queryNonEmptyText = lambda obj: obj if getattr(obj, "text", "") else None self.utility.findNextObject = lambda obj: getattr(obj, "next_obj", None) self.utility.findPreviousObject = lambda obj: getattr(obj, "prev_obj", None) self.utility.getDocumentForObject = lambda obj: self.document if isinstance(obj, FakeTextObject) else obj if obj is self.document else None self.utility.isSpreadSheetCell = lambda obj: False @staticmethod def _make_selection(start_object, start_offset, end_object, end_offset, start_is_active=False): return types.SimpleNamespace( start_object=start_object, start_offset=start_offset, end_object=end_object, end_offset=end_offset, start_is_active=start_is_active, ) @staticmethod def _new_text_selection(): return types.SimpleNamespace( start_object=None, start_offset=0, end_object=None, end_offset=0, start_is_active=False, ) def _patch_environment(self, local_ranges=None): local_ranges = local_ranges or {} remove_selection = mock.Mock() set_selected_text = mock.Mock() def get_selected_ranges(obj): return list(local_ranges.get(obj, [])) def get_character_count(obj): return len(obj.text) def get_caret_offset(obj): return obj.caret_offset def get_substring(obj, start, end): if end == -1: end = len(obj.text) return obj.text[start:end] def get_text_selections(document): return list(document.currentSelections) def set_text_selections(document, selections): document.setCalls.append(list(selections)) document.currentSelections = list(selections) return True stack = ExitStack() stack.enter_context(mock.patch("cthulhu.script_utilities.AXObject.supports_text", side_effect=lambda obj: isinstance(obj, FakeTextObject))) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText.get_selected_ranges", side_effect=get_selected_ranges)) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText.get_character_count", side_effect=get_character_count)) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText.get_caret_offset", side_effect=get_caret_offset)) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText.get_substring", side_effect=get_substring)) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText.set_selected_text", set_selected_text)) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText._get_n_selections", return_value=1)) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText._remove_selection", remove_selection)) stack.enter_context(mock.patch("cthulhu.script_utilities.Atspi.Document.get_text_selections", side_effect=get_text_selections)) stack.enter_context(mock.patch("cthulhu.script_utilities.Atspi.Document.set_text_selections", side_effect=set_text_selections)) stack.enter_context(mock.patch("cthulhu.script_utilities.Atspi.TextSelection", side_effect=self._new_text_selection)) stack.enter_context( mock.patch( "cthulhu.script_utilities.cthulhu.cthulhuApp", new=types.SimpleNamespace( settingsManager=types.SimpleNamespace( getSetting=lambda *_args, **_kwargs: False, ) ), create=True, ) ) self.addCleanup(stack.close) return remove_selection, set_selected_text def test_selected_text_uses_document_selection_when_local_ranges_are_empty(self): self.document.currentSelections = [ self._make_selection(self.first, 2, self.second, 2), ] self._patch_environment() self.assertEqual(self.utility.selectedText(self.first), ["pha", 2, 5]) self.assertEqual(self.utility.selectedText(self.second), ["be", 0, 2]) def test_all_text_selections_project_document_ranges_onto_each_object(self): self.document.currentSelections = [ self._make_selection(self.first, 2, self.second, 2), ] self._patch_environment() self.assertEqual(self.utility.allTextSelections(self.first), [(2, 5)]) self.assertEqual(self.utility.allTextSelections(self.second), [(0, 2)]) def test_all_selected_text_aggregates_multi_object_document_selection(self): self.document.currentSelections = [ self._make_selection(self.first, 2, self.second, 2), ] self._patch_environment() self.assertEqual(self.utility.allSelectedText(self.first), ("pha be", 2, 5)) def test_clear_text_selection_uses_document_api_when_selection_is_authoritative(self): self.document.currentSelections = [ self._make_selection(self.first, 1, self.second, 3), ] remove_selection, _ = self._patch_environment() self.utility.clearTextSelection(self.first) self.assertEqual(self.document.setCalls, [[]]) remove_selection.assert_not_called() def test_adjust_text_selection_preserves_other_document_ranges(self): self.document.currentSelections = [ self._make_selection(self.first, 2, self.first, 4), self._make_selection(self.second, 1, self.second, 3), ] self.first.caret_offset = 4 _, set_selected_text = self._patch_environment() self.utility.adjustTextSelection(self.first, 5) self.assertEqual(len(self.document.setCalls), 1) stored = [ (selection.start_object, selection.start_offset, selection.end_object, selection.end_offset, selection.start_is_active) for selection in self.document.setCalls[0] ] self.assertEqual( stored, [ (self.first, 2, self.first, 5, False), (self.second, 1, self.second, 3, False), ], ) set_selected_text.assert_not_called() def test_selected_text_falls_back_to_raw_substring_when_expand_eocs_is_empty(self): self.document.currentSelections = [] self.utility.expandEOCs = lambda _obj, _start=0, _end=-1: "" self._patch_environment(local_ranges={self.first: [(2, 5)]}) self.assertEqual(self.utility.selectedText(self.first), ["pha", 2, 5]) if __name__ == "__main__": unittest.main()