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.
This commit is contained in:
472
tests/test_default_script_clipboard_regressions.py
Normal file
472
tests/test_default_script_clipboard_regressions.py
Normal file
@@ -0,0 +1,472 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Atspi", "2.0")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from gi.repository import Atspi
|
||||
|
||||
from cthulhu import messages
|
||||
from cthulhu.scripts import default
|
||||
|
||||
|
||||
CONTROL_MASK = 1 << Atspi.ModifierType.CONTROL
|
||||
|
||||
|
||||
class FakeKeyboardEvent:
|
||||
def __init__(self, pressed, keyval_name, modifiers, obj, event_string=None):
|
||||
self._pressed = pressed
|
||||
self.keyval_name = keyval_name
|
||||
self.modifiers = modifiers
|
||||
self._obj = obj
|
||||
self.event_string = keyval_name if event_string is None else event_string
|
||||
|
||||
def get_object(self):
|
||||
return self._obj
|
||||
|
||||
def is_pressed_key(self):
|
||||
return self._pressed
|
||||
|
||||
|
||||
def run_repeating_timeout(_delay, callback, generation, max_checks=10):
|
||||
for _ in range(max_checks):
|
||||
if not callback(generation):
|
||||
break
|
||||
|
||||
|
||||
class DefaultScriptClipboardRegressionTests(unittest.TestCase):
|
||||
def _make_script(self):
|
||||
script = object.__new__(default.Script)
|
||||
script.utilities = mock.Mock()
|
||||
script.presentMessage = mock.Mock()
|
||||
script.flatReviewPresenter = mock.Mock()
|
||||
script.flatReviewPresenter.is_active.return_value = False
|
||||
script._pendingKeyboardClipboardCommand = None
|
||||
script._pendingKeyboardClipboardRelease = None
|
||||
script._keyboardClipboardCommandGeneration = 0
|
||||
script._sessionType = "x11"
|
||||
return script
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=lambda _delay, callback, generation: callback(generation),
|
||||
)
|
||||
def test_keyboard_copy_fallback_presents_message_when_callback_never_arrives(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["before", "copied text"]
|
||||
script.utilities.objectContentsAreInClipboard.return_value = True
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(True, "c", CONTROL_MASK, source), None)
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(False, "c", CONTROL_MASK, source), None)
|
||||
|
||||
script.utilities.objectContentsAreInClipboard.assert_called_once_with(source)
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=run_repeating_timeout,
|
||||
)
|
||||
def test_keyboard_copy_fallback_uses_captured_selection_when_clipboard_stays_empty(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["before"] + [""] * 10
|
||||
script.utilities.allSelectedText.return_value = ("The quick brown", 0, 15)
|
||||
script.utilities.selectedText.return_value = ("The quick brown", 0, 15)
|
||||
script.utilities.objectContentsAreInClipboard.return_value = False
|
||||
script.utilities.isInActiveApp.return_value = False
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(True, "c", CONTROL_MASK, source), None)
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(False, "c", CONTROL_MASK, source), None)
|
||||
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=lambda _delay, callback, generation: callback(generation),
|
||||
)
|
||||
def test_keyboard_copy_fallback_handles_release_before_press_order(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["before", ""]
|
||||
script.utilities.allSelectedText.return_value = ("3086180", 0, 7)
|
||||
script.utilities.selectedText.return_value = ("3086180", 0, 7)
|
||||
script.utilities.objectContentsAreInClipboard.return_value = False
|
||||
script.utilities.isInActiveApp.return_value = False
|
||||
|
||||
script.updateKeyboardEventState(
|
||||
FakeKeyboardEvent(False, "0x3", CONTROL_MASK, source, "\x03"),
|
||||
None,
|
||||
)
|
||||
script.updateKeyboardEventState(
|
||||
FakeKeyboardEvent(True, "0x3", CONTROL_MASK, source, "\x03"),
|
||||
None,
|
||||
)
|
||||
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch("cthulhu.scripts.default.GLib.idle_add")
|
||||
@mock.patch("cthulhu.scripts.default.GLib.timeout_add")
|
||||
def test_wayland_keyboard_copy_is_presented_immediately(
|
||||
self,
|
||||
timeout_add,
|
||||
idle_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
script._sessionType = "wayland"
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.return_value = ""
|
||||
script.utilities.allSelectedText.return_value = ("The quick brown", 0, 15)
|
||||
script.utilities.selectedText.return_value = ("The quick brown", 0, 15)
|
||||
script.utilities.objectContentsAreInClipboard.return_value = False
|
||||
script.utilities.isInActiveApp.return_value = False
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(True, "c", CONTROL_MASK, source), None)
|
||||
|
||||
timeout_add.assert_not_called()
|
||||
idle_add.assert_called_once()
|
||||
script.presentMessage.assert_not_called()
|
||||
|
||||
callback, generation = idle_add.call_args.args
|
||||
callback(generation)
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(False, "c", CONTROL_MASK, source), None)
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch("cthulhu.scripts.default.GLib.idle_add")
|
||||
@mock.patch("cthulhu.scripts.default.GLib.timeout_add")
|
||||
def test_wayland_keyboard_copy_handles_release_before_press_order_immediately(
|
||||
self,
|
||||
timeout_add,
|
||||
idle_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
script._sessionType = "wayland"
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.return_value = ""
|
||||
script.utilities.allSelectedText.return_value = ("3086180", 0, 7)
|
||||
script.utilities.selectedText.return_value = ("3086180", 0, 7)
|
||||
script.utilities.objectContentsAreInClipboard.return_value = False
|
||||
script.utilities.isInActiveApp.return_value = False
|
||||
|
||||
script.updateKeyboardEventState(
|
||||
FakeKeyboardEvent(False, "0x3", CONTROL_MASK, source, "\x03"),
|
||||
None,
|
||||
)
|
||||
script.updateKeyboardEventState(
|
||||
FakeKeyboardEvent(True, "0x3", CONTROL_MASK, source, "\x03"),
|
||||
None,
|
||||
)
|
||||
|
||||
timeout_add.assert_not_called()
|
||||
idle_add.assert_called_once()
|
||||
script.presentMessage.assert_not_called()
|
||||
|
||||
callback, generation = idle_add.call_args.args
|
||||
callback(generation)
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=lambda _delay, callback, generation: callback(generation),
|
||||
)
|
||||
def test_keyboard_copy_fallback_recognizes_control_character_key_name(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["before", "copied text"]
|
||||
script.utilities.objectContentsAreInClipboard.return_value = True
|
||||
|
||||
script.updateKeyboardEventState(
|
||||
FakeKeyboardEvent(True, "0x3", CONTROL_MASK, source, "\x03"),
|
||||
None,
|
||||
)
|
||||
script.updateKeyboardEventState(
|
||||
FakeKeyboardEvent(False, "0x3", CONTROL_MASK, source, "\x03"),
|
||||
None,
|
||||
)
|
||||
|
||||
script.utilities.objectContentsAreInClipboard.assert_called_once_with(source)
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=lambda _delay, callback, generation: callback(generation),
|
||||
)
|
||||
def test_keyboard_copy_fallback_uses_captured_source_not_current_focus(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["before", "copied text"]
|
||||
script.utilities.objectContentsAreInClipboard.return_value = True
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(True, "c", CONTROL_MASK, source), None)
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(False, "c", CONTROL_MASK, object()), None)
|
||||
|
||||
script.utilities.objectContentsAreInClipboard.assert_called_once_with(source)
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=lambda _delay, callback, generation: callback(generation),
|
||||
)
|
||||
def test_keyboard_copy_fallback_stays_silent_if_clipboard_is_unchanged(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["same text", "same text"]
|
||||
script.utilities.objectContentsAreInClipboard.return_value = False
|
||||
script.utilities.isInActiveApp.return_value = True
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(True, "c", CONTROL_MASK, source), None)
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(False, "c", CONTROL_MASK, source), None)
|
||||
|
||||
self.assertEqual(
|
||||
script.utilities.objectContentsAreInClipboard.call_args_list,
|
||||
[mock.call(source), mock.call()],
|
||||
)
|
||||
script.presentMessage.assert_not_called()
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=lambda _delay, callback, generation: callback(generation),
|
||||
)
|
||||
def test_keyboard_copy_fallback_presents_when_clipboard_unchanged_but_matches_source(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["same text", "same text"]
|
||||
script.utilities.objectContentsAreInClipboard.return_value = True
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(True, "c", CONTROL_MASK, source), None)
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(False, "c", CONTROL_MASK, source), None)
|
||||
|
||||
script.utilities.objectContentsAreInClipboard.assert_called_once_with(source)
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=lambda _delay, callback, generation: callback(generation),
|
||||
)
|
||||
def test_keyboard_copy_fallback_uses_current_focus_when_captured_source_no_longer_matches(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["before", "copied text"]
|
||||
script.utilities.objectContentsAreInClipboard.side_effect = [False, True]
|
||||
script.utilities.isInActiveApp.return_value = True
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(True, "c", CONTROL_MASK, source), None)
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(False, "c", CONTROL_MASK, source), None)
|
||||
|
||||
self.assertEqual(
|
||||
script.utilities.objectContentsAreInClipboard.call_args_list,
|
||||
[mock.call(source), mock.call()],
|
||||
)
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=lambda _delay, callback, generation: callback(generation),
|
||||
)
|
||||
def test_keyboard_copy_fallback_does_not_probe_current_focus_when_source_left_active_app(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["before", "copied text"]
|
||||
script.utilities.objectContentsAreInClipboard.return_value = False
|
||||
script.utilities.isInActiveApp.return_value = False
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(True, "c", CONTROL_MASK, source), None)
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(False, "c", CONTROL_MASK, source), None)
|
||||
|
||||
script.utilities.objectContentsAreInClipboard.assert_called_once_with(source)
|
||||
script.presentMessage.assert_not_called()
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=run_repeating_timeout,
|
||||
)
|
||||
def test_keyboard_copy_fallback_retries_until_clipboard_match_appears(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["before", "before", "copied text"]
|
||||
script.utilities.objectContentsAreInClipboard.side_effect = [False, True]
|
||||
script.utilities.isInActiveApp.return_value = False
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(True, "c", CONTROL_MASK, source), None)
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(False, "c", CONTROL_MASK, source), None)
|
||||
|
||||
self.assertEqual(
|
||||
script.utilities.objectContentsAreInClipboard.call_args_list,
|
||||
[mock.call(source), mock.call(source)],
|
||||
)
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=lambda _delay, callback, generation: callback(generation),
|
||||
)
|
||||
def test_keyboard_copy_fallback_uses_captured_selected_text_when_live_selection_clears(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["before", "copied text"]
|
||||
script.utilities.allSelectedText.return_value = ("copied text", 0, 11)
|
||||
script.utilities.selectedText.return_value = ("", 0, 0)
|
||||
script.utilities.objectContentsAreInClipboard.return_value = False
|
||||
script.utilities.isInActiveApp.return_value = False
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(True, "c", CONTROL_MASK, source), None)
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(False, "c", CONTROL_MASK, source), None)
|
||||
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=lambda _delay, callback, generation: callback(generation),
|
||||
)
|
||||
def test_clipboard_callback_after_fallback_does_not_duplicate_message(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["before", "copied text", "copied text"]
|
||||
script.utilities.objectContentsAreInClipboard.return_value = True
|
||||
script.utilities.topLevelObjectIsActiveAndCurrent.return_value = True
|
||||
script.utilities.lastInputEventWasCopy.return_value = True
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(True, "c", CONTROL_MASK, source), None)
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(False, "c", CONTROL_MASK, source), None)
|
||||
script.onClipboardContentsChanged()
|
||||
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
@mock.patch("cthulhu.scripts.default.AXUtilities.is_terminal", return_value=False)
|
||||
@mock.patch(
|
||||
"cthulhu.scripts.default.GLib.timeout_add",
|
||||
side_effect=lambda _delay, callback, generation: callback(generation),
|
||||
)
|
||||
def test_clipboard_callback_without_match_does_not_cancel_keyboard_fallback(
|
||||
self,
|
||||
_timeout_add,
|
||||
_is_terminal,
|
||||
):
|
||||
script = self._make_script()
|
||||
source = object()
|
||||
script.utilities.getClipboardContents.side_effect = ["before", "copied text", "copied text"]
|
||||
script.utilities.objectContentsAreInClipboard.side_effect = [False, False, True]
|
||||
script.utilities.isInActiveApp.return_value = False
|
||||
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(True, "c", CONTROL_MASK, source), None)
|
||||
script.onClipboardContentsChanged()
|
||||
script.updateKeyboardEventState(FakeKeyboardEvent(False, "c", CONTROL_MASK, source), None)
|
||||
|
||||
self.assertEqual(
|
||||
script.utilities.objectContentsAreInClipboard.call_args_list,
|
||||
[mock.call(source), mock.call(), mock.call(source)],
|
||||
)
|
||||
script.presentMessage.assert_called_once_with(
|
||||
messages.CLIPBOARD_COPIED_FULL,
|
||||
messages.CLIPBOARD_COPIED_BRIEF,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
223
tests/test_document_selection_regressions.py
Normal file
223
tests/test_document_selection_regressions.py
Normal file
@@ -0,0 +1,223 @@
|
||||
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()
|
||||
@@ -20,7 +20,9 @@ if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "Sound
|
||||
|
||||
soundGeneratorModule.SoundGenerator = _StubSoundGenerator
|
||||
|
||||
from cthulhu import messages
|
||||
from cthulhu.scripts.web import script as web_script
|
||||
from cthulhu.scripts.web import script_utilities as web_script_utilities
|
||||
|
||||
|
||||
class WebKeyGrabRegressionTests(unittest.TestCase):
|
||||
@@ -100,3 +102,130 @@ class WebKeyGrabRegressionTests(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class WebSelectionRegressionTests(unittest.TestCase):
|
||||
def _make_script(self):
|
||||
testScript = web_script.Script.__new__(web_script.Script)
|
||||
testScript.pointOfReference = {}
|
||||
testScript.utilities = mock.Mock()
|
||||
testScript.utilities.sanityCheckActiveWindow = mock.Mock()
|
||||
testScript.utilities.isZombie.return_value = False
|
||||
testScript.utilities.eventIsBrowserUINoise.return_value = False
|
||||
testScript.utilities.eventIsBrowserUIAutocompleteNoise.return_value = False
|
||||
testScript.utilities.lastInputEventWasTab.return_value = False
|
||||
testScript.utilities.inFindContainer.return_value = False
|
||||
testScript.utilities.eventIsFromLocusOfFocusDocument.return_value = True
|
||||
testScript.utilities.textEventIsDueToInsertion.return_value = False
|
||||
testScript.utilities.textEventIsDueToDeletion.return_value = False
|
||||
testScript.utilities.isItemForEditableComboBox.return_value = False
|
||||
testScript.utilities.eventIsAutocompleteNoise.return_value = False
|
||||
testScript.utilities.eventIsSpinnerNoise.return_value = False
|
||||
testScript.utilities.caretMovedOutsideActiveGrid.return_value = False
|
||||
testScript.utilities.treatEventAsSpinnerValueChange.return_value = False
|
||||
testScript.utilities.queryNonEmptyText.return_value = "Darkest Dungeon"
|
||||
testScript.utilities.lastInputEventWasCaretNavWithSelection.return_value = True
|
||||
testScript.utilities.getCharacterContentsAtOffset.return_value = [[None, 0, 1, "D"]]
|
||||
testScript.utilities.pathComparison.return_value = -1
|
||||
testScript.updateBraille = mock.Mock()
|
||||
testScript.speakContents = mock.Mock()
|
||||
testScript.speakMessage = mock.Mock()
|
||||
testScript._lastCommandWasCaretNav = True
|
||||
testScript._lastCommandWasStructNav = False
|
||||
testScript._lastCommandWasMouseButton = False
|
||||
testScript._lastMouseButtonContext = (None, -1)
|
||||
testScript._inFocusMode = False
|
||||
testScript._focusModeIsSticky = False
|
||||
testScript._browseModeIsSticky = False
|
||||
return testScript
|
||||
|
||||
def test_broken_web_caret_selection_synthesizes_selection_from_caret_context(self):
|
||||
testScript = self._make_script()
|
||||
document = object()
|
||||
section = object()
|
||||
event = mock.Mock(source=section, detail1=-1)
|
||||
manager = mock.Mock()
|
||||
manager.last_event_was_forward_caret_selection.return_value = True
|
||||
manager.last_event_was_backward_caret_selection.return_value = False
|
||||
|
||||
testScript.utilities.getTopLevelDocumentForObject.return_value = document
|
||||
testScript.utilities.getCaretContext.return_value = (section, 0)
|
||||
testScript.utilities.nextContext.return_value = (section, 1)
|
||||
testScript.utilities.getCharacterContentsAtOffset.return_value = [[section, 0, 1, "D"]]
|
||||
|
||||
with (
|
||||
mock.patch.object(web_script.cthulhu_state, "locusOfFocus", section),
|
||||
mock.patch.object(web_script.input_event_manager, "get_manager", return_value=manager),
|
||||
mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
|
||||
):
|
||||
result = web_script.Script.onCaretMoved(testScript, event)
|
||||
|
||||
self.assertTrue(result)
|
||||
testScript.utilities.setCaretContext.assert_called_once_with(section, 1, document)
|
||||
setLocusOfFocus.assert_called_once_with(event, section, False, True)
|
||||
testScript.speakContents.assert_called_once_with([[section, 0, 1, "D"]])
|
||||
testScript.speakMessage.assert_called_once_with(messages.TEXT_SELECTED, interrupt=False)
|
||||
self.assertEqual(
|
||||
testScript.pointOfReference["syntheticWebSelection"]["string"],
|
||||
"D",
|
||||
)
|
||||
|
||||
def test_broken_web_text_selection_event_synthesizes_selection_from_event_source(self):
|
||||
testScript = self._make_script()
|
||||
document = object()
|
||||
anchor = object()
|
||||
link = object()
|
||||
event = mock.Mock(source=link)
|
||||
|
||||
testScript.utilities.getTopLevelDocumentForObject.return_value = document
|
||||
testScript.utilities.getCaretContext.return_value = (anchor, 0)
|
||||
testScript.utilities.nextContext.side_effect = [(anchor, 0)]
|
||||
testScript.utilities.getCharacterContentsAtOffset.side_effect = (
|
||||
lambda obj, _offset: [[link, 0, 1, "S"]] if obj is link else []
|
||||
)
|
||||
testScript.utilities.textEventIsForNonNavigableTextObject.return_value = False
|
||||
testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = False
|
||||
testScript._compareCaretContexts = mock.Mock(return_value=1)
|
||||
|
||||
with (
|
||||
mock.patch.object(web_script.cthulhu_state, "locusOfFocus", anchor),
|
||||
mock.patch.object(web_script.AXText, "get_caret_offset", return_value=0),
|
||||
mock.patch.object(web_script.AXText, "get_selected_ranges", return_value=[]),
|
||||
mock.patch.object(web_script.AXText, "get_substring", return_value="S"),
|
||||
mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
|
||||
):
|
||||
result = web_script.Script.onTextSelectionChanged(testScript, event)
|
||||
|
||||
self.assertTrue(result)
|
||||
testScript.utilities.setCaretContext.assert_called_once_with(link, 0, document)
|
||||
setLocusOfFocus.assert_called_once_with(event, link, False, True)
|
||||
testScript.speakContents.assert_called_once_with([[link, 0, 1, "S"]])
|
||||
testScript.speakMessage.assert_called_once_with(messages.TEXT_SELECTED, interrupt=False)
|
||||
self.assertEqual(
|
||||
testScript.pointOfReference["syntheticWebSelection"]["string"],
|
||||
"S",
|
||||
)
|
||||
|
||||
def test_web_utilities_all_selected_text_falls_back_to_synthetic_selection(self):
|
||||
testScript = mock.Mock(pointOfReference={})
|
||||
utilities = web_script_utilities.Utilities.__new__(web_script_utilities.Utilities)
|
||||
utilities._script = testScript
|
||||
document = object()
|
||||
obj = object()
|
||||
testScript.pointOfReference["syntheticWebSelection"] = {
|
||||
"document": document,
|
||||
"string": "Dark",
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
web_script_utilities.script_utilities.Utilities,
|
||||
"allSelectedText",
|
||||
return_value=["", 0, 0],
|
||||
),
|
||||
mock.patch.object(utilities, "inDocumentContent", return_value=True),
|
||||
mock.patch.object(utilities, "getTopLevelDocumentForObject", return_value=document),
|
||||
):
|
||||
result = web_script_utilities.Utilities.allSelectedText(utilities, obj)
|
||||
|
||||
self.assertEqual(result, ["Dark", 0, 4])
|
||||
|
||||
Reference in New Issue
Block a user