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()