Files
cthulhu/tests/test_document_selection_regressions.py
Hunter Jozwiak 04c79f2e0f Implement AT-SPI selection bridging groundwork
Add the document selection adapter, integrate it through script utilities and major callers, and package the clipboard fallback work that was needed during manual testing on Wayland.

Also include a handoff note for the still-open browser link-selection issue so other developers can continue from the current branch state without reconstructing the debug trail.
2026-04-09 05:22:00 -04:00

224 lines
8.4 KiB
Python

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"<FakeTextObject {self.name}>"
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()