From 1249aa791c072a2825324ad14265bb76a915c460 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 21 Jul 2026 13:28:26 -0400 Subject: [PATCH] More web refactor. Tighten suspend terminal code. --- src/cthulhu/input_event_manager.py | 176 +++- .../scripts/toolkits/Chromium/script.py | 46 +- .../toolkits/Chromium/script_utilities.py | 14 + src/cthulhu/scripts/web/script.py | 132 ++- src/cthulhu/scripts/web/script_utilities.py | 27 - tests/test_chromium_omnibox_regressions.py | 183 ++++ ...t_event_manager_key_watcher_regressions.py | 20 + ...put_event_manager_x11_focus_regressions.py | 108 ++ tests/test_web_input_regressions.py | 994 ++++++++++++++++++ 9 files changed, 1636 insertions(+), 64 deletions(-) diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py index f4c3e35..f04753c 100644 --- a/src/cthulhu/input_event_manager.py +++ b/src/cthulhu/input_event_manager.py @@ -121,6 +121,9 @@ class InputEventManager: self._wnck = None self._did_attempt_wnck_load: bool = False self._scriptWithSuspendedGrabsForXterm = None + self._xtermFocusScreen = None + self._xtermFocusHandlerId: int = 0 + self._xtermRecoverySourceId: int = 0 def activate_device(self) -> Atspi.Device: """Creates and returns the AT-SPI device used by this manager.""" @@ -196,6 +199,9 @@ class InputEventManager: self._key_pressed_id = 0 self._key_released_id = 0 self._device = None + self._stop_xterm_focus_monitor() + self._stop_xterm_recovery_timer() + self._scriptWithSuspendedGrabsForXterm = None def get_debug_snapshot(self) -> Dict[str, object]: """Returns read-only counters useful for long-uptime diagnostics.""" @@ -315,17 +321,27 @@ class InputEventManager: return [] grab_ids = [] - for kd in binding.key_definitions(): - definitionKeycode = kd.keycode or binding.keycode - if self._signal_event_uses_selective_legacy_listener( - definitionKeycode, kd.modifiers - ): - continue - grab_id = self._device.add_key_grab(kd, None) - if grab_id == 0: - continue - grab_ids.append(grab_id) - self._grabbed_bindings[grab_id] = binding + try: + for kd in binding.key_definitions(): + definitionKeycode = kd.keycode or binding.keycode + if self._signal_event_uses_selective_legacy_listener( + definitionKeycode, kd.modifiers + ): + continue + grab_id = self._device.add_key_grab(kd, None) + if grab_id == 0: + continue + grab_ids.append(grab_id) + self._grabbed_bindings[grab_id] = binding + except Exception: + for grab_id in grab_ids: + try: + self._device.remove_key_grab(grab_id) + except Exception as error: + msg = f"INPUT EVENT MANAGER: Could not roll back key grab {grab_id}: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._grabbed_bindings.pop(grab_id, None) + raise return grab_ids @@ -519,6 +535,7 @@ class InputEventManager: screen = wnck.Screen.get_default() if screen is None: return None + self._ensure_xterm_focus_monitor(screen) screen.force_update() return screen.get_active_window() except Exception as error: @@ -588,10 +605,118 @@ class InputEventManager: ] debug.print_tokens(debug.LEVEL_INFO, tokens, True) + def _stop_xterm_focus_monitor(self) -> None: + """Stops monitoring X11 focus changes used for XTerm grab recovery.""" + + screen = self._xtermFocusScreen + handlerId = self._xtermFocusHandlerId + self._xtermFocusScreen = None + self._xtermFocusHandlerId = 0 + if screen is None or not handlerId: + return + + try: + screen.disconnect(handlerId) + except Exception as error: + msg = f"INPUT EVENT MANAGER: Could not stop XTerm focus monitor: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + def _ensure_xterm_focus_monitor(self, screen: Any) -> None: + """Monitors X11 focus so suspended grabs recover without another key event.""" + + if screen is self._xtermFocusScreen and self._xtermFocusHandlerId: + return + + self._stop_xterm_focus_monitor() + try: + handlerId = screen.connect( + "active-window-changed", + self._on_active_x11_window_changed, + ) + except Exception as error: + msg = f"INPUT EVENT MANAGER: Could not start XTerm focus monitor: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + self._xtermFocusScreen = screen + self._xtermFocusHandlerId = handlerId + + def _stop_xterm_recovery_timer(self) -> None: + """Stops the fallback timer used to recover XTerm-suspended grabs.""" + + sourceId = self._xtermRecoverySourceId + self._xtermRecoverySourceId = 0 + if not sourceId: + return + + try: + GLib.source_remove(sourceId) + except GLib.GError as error: + msg = f"INPUT EVENT MANAGER: Could not stop XTerm recovery timer: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + def _ensure_xterm_recovery_timer(self) -> None: + """Starts fallback recovery when X11 focus notifications are insufficient.""" + + if self._device is None or self._xtermRecoverySourceId: + return + + self._xtermRecoverySourceId = GLib.timeout_add( + 100, + self._poll_xterm_grab_recovery, + ) + + def _poll_xterm_grab_recovery(self) -> bool: + """Checks whether XTerm-suspended grabs can now be restored.""" + + if self._scriptWithSuspendedGrabsForXterm is None: + self._xtermRecoverySourceId = 0 + return False + + match = self._active_x11_window_xterm_match() + tokens = ["INPUT EVENT MANAGER: XTerm recovery matcher returned", match] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if match is False: + self._xtermRecoverySourceId = 0 + self._restore_key_grabs_after_xterm() + return False + + keepPolling = not self._xtermFocusHandlerId or match is None + if not keepPolling: + self._xtermRecoverySourceId = 0 + return keepPolling + + def _on_active_x11_window_changed(self, screen: Any, _previousWindow: Any) -> None: + """Restores suspended grabs as soon as X11 focus leaves XTerm.""" + + if self._scriptWithSuspendedGrabsForXterm is None: + return + + try: + window = screen.get_active_window() + except Exception as error: + msg = f"INPUT EVENT MANAGER: Could not check changed X11 focus: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._ensure_xterm_recovery_timer() + return + + match = self._x11_window_xterm_match(window) + tokens = ["INPUT EVENT MANAGER: XTerm focus-change matcher returned", match] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if match is False: + self._restore_key_grabs_after_xterm() + elif match is None: + self._ensure_xterm_recovery_timer() + def _active_x11_window_xterm_match(self) -> Optional[bool]: """Returns whether the active X11 window is XTerm, or None when unknown.""" window = self._get_active_x11_window() + return self._x11_window_xterm_match(window) + + def _x11_window_xterm_match(self, window: Any) -> Optional[bool]: + """Returns whether window is XTerm, or None when it cannot be identified.""" + if window is None: msg = "INPUT EVENT MANAGER: XTerm matcher cannot identify active X11 window." debug.print_message(debug.LEVEL_INFO, msg, True) @@ -816,12 +941,13 @@ class InputEventManager: debug.print_tokens(debug.LEVEL_INFO, tokens, True) removeGrabs() self._scriptWithSuspendedGrabsForXterm = script + if not self._xtermFocusHandlerId: + self._ensure_xterm_recovery_timer() def _restore_key_grabs_after_xterm(self) -> None: """Restores key grabs after leaving XTerm.""" script = self._scriptWithSuspendedGrabsForXterm - self._scriptWithSuspendedGrabsForXterm = None if script is None: return @@ -833,6 +959,8 @@ class InputEventManager: activeScript, ] debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._scriptWithSuspendedGrabsForXterm = None + self._stop_xterm_recovery_timer() return addGrabs = getattr(script, "addKeyGrabs", None) @@ -844,7 +972,29 @@ class InputEventManager: script, ] debug.print_tokens(debug.LEVEL_INFO, tokens, True) - addGrabs() + try: + addGrabs() + except Exception as error: + msg = f"INPUT EVENT MANAGER: Could not restore XTerm-suspended key grabs: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + removeGrabs = getattr(script, "removeKeyGrabs", None) + if callable(removeGrabs): + try: + removeGrabs() + except Exception as rollbackError: + msg = ( + "INPUT EVENT MANAGER: Could not roll back partial XTerm grab " + f"restoration: {rollbackError}" + ) + debug.print_message(debug.LEVEL_INFO, msg, True) + self._scriptWithSuspendedGrabsForXterm = None + self._stop_xterm_recovery_timer() + return + self._ensure_xterm_recovery_timer() + return + + self._scriptWithSuspendedGrabsForXterm = None + self._stop_xterm_recovery_timer() # pylint: disable=too-many-arguments # pylint: disable=too-many-positional-arguments diff --git a/src/cthulhu/scripts/toolkits/Chromium/script.py b/src/cthulhu/scripts/toolkits/Chromium/script.py index b98de3d..7da3440 100644 --- a/src/cthulhu/scripts/toolkits/Chromium/script.py +++ b/src/cthulhu/scripts/toolkits/Chromium/script.py @@ -34,6 +34,7 @@ __license__ = "LGPL" from cthulhu import debug from cthulhu import cthulhu from cthulhu import cthulhu_state +from cthulhu import keybindings from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities from cthulhu.scripts import default @@ -49,6 +50,7 @@ class Script(web.Script): super().__init__(app) self.presentIfInactive = False + self._lastAutocompletePopupItem = None def getBrailleGenerator(self): """Returns the braille generator for this script.""" @@ -325,7 +327,9 @@ class Script(web.Script): if AXUtilities.is_frame(parent) and not AXObject.get_name(parent): msg = "CHROMIUM: Event source believed to be in autocomplete popup" debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu_state.locusOfFocus = event.source + if event.source != self._lastAutocompletePopupItem: + self.presentObject(event.source, interrupt=True) + self._lastAutocompletePopupItem = event.source return msg = "CHROMIUM: Passing along event to default script" @@ -416,6 +420,22 @@ class Script(web.Script): if not self.utilities.canBeActiveWindow(event.source): return + autocomplete = self.utilities.autocompletePopupForFrame(event.source) + if autocomplete: + msg = "CHROMIUM: Treating nameless listbox frame as autocomplete popup" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + selected = self.utilities.selectedChildren(autocomplete) + activeItem = selected[0] if len(selected) == 1 else None + if activeItem is None: + activeItem = AXUtilities.get_focused_object(autocomplete) + activeItem = activeItem or autocomplete + tokens = ["CHROMIUM: Presenting active autocomplete popup item", activeItem] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + self.presentObject(activeItem, interrupt=True) + self._lastAutocompletePopupItem = activeItem + return + # If this is a frame for a popup menu, we don't want to treat # it like a proper window:activate event because it's not as # far as the end-user experience is concerned. @@ -460,6 +480,30 @@ class Script(web.Script): def onWindowDeactivated(self, event): """Callback for window:deactivate accessibility events.""" + if self.utilities.autocompletePopupForFrame(event.source): + msg = "CHROMIUM: Ignoring autocomplete popup deactivation" + debug.printMessage(debug.LEVEL_INFO, msg, True) + self._lastAutocompletePopupItem = None + return + + focus = cthulhu_state.locusOfFocus + isAutocompleteCombo = AXUtilities.is_combo_box(focus) \ + and AXUtilities.supports_autocompletion(focus) + if isAutocompleteCombo: + lastKey, mods = self.utilities.lastKeyAndModifiers() + if lastKey in ("Down", "Up") and mods & keybindings.ALT_MODIFIER_MASK: + msg = "CHROMIUM: Preserving state while opening autocomplete popup" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + + app = AXObject.get_application(event.source) + for child in AXObject.iter_children(app): + popup = self.utilities.autocompletePopupForFrame(child) + if popup and AXUtilities.is_showing(child): + msg = "CHROMIUM: Preserving state during autocomplete popup activation" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + if super().onWindowDeactivated(event): return diff --git a/src/cthulhu/scripts/toolkits/Chromium/script_utilities.py b/src/cthulhu/scripts/toolkits/Chromium/script_utilities.py index a3110be..7ae2954 100644 --- a/src/cthulhu/scripts/toolkits/Chromium/script_utilities.py +++ b/src/cthulhu/scripts/toolkits/Chromium/script_utilities.py @@ -170,6 +170,20 @@ class Utilities(web.Utilities): debug.printTokens(debug.LEVEL_INFO, tokens, True) return menu + def autocompletePopupForFrame(self, obj): + """Returns the listbox in a Chromium autocomplete popup frame.""" + + if not AXUtilities.is_frame(obj) or AXObject.get_name(obj): + return None + + # Native datalist/autofill popups are exposed as a direct listbox child + # of a separate nameless frame, without a popup-for relation. + if AXObject.get_child_count(obj) != 1: + return None + + child = AXObject.get_child(obj, 0) + return child if AXUtilities.is_list_box(child) else None + def topLevelObject(self, obj, useFallbackSearch=False): if not obj: return None diff --git a/src/cthulhu/scripts/web/script.py b/src/cthulhu/scripts/web/script.py index 00be06a..93902e6 100644 --- a/src/cthulhu/scripts/web/script.py +++ b/src/cthulhu/scripts/web/script.py @@ -2175,14 +2175,18 @@ class Script(default.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return True - if self.utilities.textEventIsDueToInsertion(event): - msg = "WEB: Event handled: Updating position due to insertion" + typingReasons = (TextEventReason.TYPING, TextEventReason.TYPING_ECHOABLE) + if reason in typingReasons \ + and event.source == cthulhu_state.locusOfFocus \ + and AXUtilities.is_editable(event.source): + msg = f"WEB: Event handled: Updating position due to {reason}" debug.printMessage(debug.LEVEL_INFO, msg, True) self._saveLastCursorPosition(event.source, event.detail1) return True - if self.utilities.textEventIsDueToDeletion(event): - msg = "WEB: Event handled: Updating position due to deletion" + deletionReasons = (TextEventReason.BACKSPACE, TextEventReason.DELETE) + if reason in deletionReasons and AXUtilities.is_editable(event.source): + msg = f"WEB: Event handled: Updating position due to {reason}" debug.printMessage(debug.LEVEL_INFO, msg, True) self._saveLastCursorPosition(event.source, event.detail1) return True @@ -2885,7 +2889,7 @@ class Script(default.Script): self._currentTextAttrs = {} return False - def onTextDeleted(self, event): + def onTextDeleted(self, event: Atspi.Event) -> bool: """Callback for object:text-changed:delete accessibility events.""" if self.utilities.isZombie(event.source): @@ -2893,26 +2897,52 @@ class Script(default.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return True + reason = AXUtilitiesEvent.get_text_event_reason(event) + if reason == TextEventReason.PAGE_SWITCH: + msg = f"WEB: Deletion is due to {reason}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + # UI and live-region classification precedes page-switch classification. + # Preserve page-switch ownership when those conditions overlap. if self.utilities.lastInputEventWasPageSwitch(): - msg = "WEB: Deletion is believed to be due to page switch" + msg = "WEB: Deletion is due to legacy page-switch fallback" debug.printMessage(debug.LEVEL_INFO, msg, True) return True + if reason == TextEventReason.LIVE_REGION_UPDATE: + msg = f"WEB: Ignoring deletion due to {reason}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + # Preserve web-specific live-region detection for values the shared + # classifier does not currently identify. if self.utilities.isLiveRegion(event.source): - msg = "WEB: Ignoring deletion from live region" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if self.utilities.eventIsBrowserUINoise(event): - msg = "WEB: Ignoring event believed to be browser UI noise" + msg = "WEB: Ignoring deletion from legacy live-region fallback" debug.printMessage(debug.LEVEL_INFO, msg, True) return True if not self.utilities.inDocumentContent(event.source): msg = "WEB: Event source is not in document content" debug.printMessage(debug.LEVEL_INFO, msg, True) + + if reason == TextEventReason.UI_UPDATE: + msg = f"WEB: Ignoring browser UI event due to {reason}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + if self.utilities.eventIsBrowserUINoise(event): + msg = "WEB: Ignoring event believed to be browser UI noise" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + return False + if reason == TextEventReason.SPIN_BUTTON_VALUE_CHANGE: + msg = f"WEB: Ignoring event due to {reason}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + if self.utilities.eventIsSpinnerNoise(event): msg = "WEB: Ignoring: Event believed to be spinner noise" debug.printMessage(debug.LEVEL_INFO, msg, True) @@ -2927,13 +2957,26 @@ class Script(default.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) self.utilities.clearContentCache() - if self.utilities.textEventIsDueToDeletion(event): - msg = "WEB: Event believed to be due to editable text deletion" + deletionReasons = (TextEventReason.BACKSPACE, TextEventReason.DELETE) + if reason in deletionReasons \ + and AXUtilities.is_editable(event.source) \ + and (reason == TextEventReason.DELETE or not self.utilities.isHidden(event.source)): + msg = f"WEB: Event is due to editable text deletion: {reason}" debug.printMessage(debug.LEVEL_INFO, msg, True) return False - if self.utilities.textEventIsDueToInsertion(event): - msg = "WEB: Ignoring event believed to be due to text insertion" + inputEvent = cthulhu_state.lastNonModifierKeyEvent + unmodifiedPrintableInput = \ + isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent) \ + and inputEvent \ + and inputEvent.is_printable_key() \ + and not inputEvent.modifiers + typingReasons = (TextEventReason.TYPING, TextEventReason.TYPING_ECHOABLE) + if reason in typingReasons \ + and event.source == cthulhu_state.locusOfFocus \ + and AXUtilities.is_editable(event.source) \ + and unmodifiedPrintableInput: + msg = f"WEB: Ignoring insertion-caused deletion: {reason}" debug.printMessage(debug.LEVEL_INFO, msg, True) return True @@ -2986,13 +3029,33 @@ class Script(default.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return True - if self.utilities.lastInputEventWasPageSwitch(): - msg = "WEB: Insertion is believed to be due to page switch" + reason = AXUtilitiesEvent.get_text_event_reason(event) + if reason == TextEventReason.PAGE_SWITCH: + msg = f"WEB: Insertion is due to {reason}" debug.printMessage(debug.LEVEL_INFO, msg, True) return True + # UI and live-region classification precedes page-switch classification. + # Preserve page-switch ownership when those conditions overlap. + if self.utilities.lastInputEventWasPageSwitch(): + msg = "WEB: Insertion is due to legacy page-switch fallback" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + if reason == TextEventReason.LIVE_REGION_UPDATE: + if self.utilities.handleAsLiveRegion(event): + msg = f"WEB: Event handled as {reason}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + self.liveRegionManager.handleEvent(event) + else: + msg = f"WEB: Ignoring unpresented {reason}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + # Preserve web-specific live-region detection for values the shared + # classifier does not currently identify, such as container-live=off. if self.utilities.handleAsLiveRegion(event): - msg = "WEB: Event to be handled as live region" + msg = "WEB: Event handled by legacy live-region fallback" debug.printMessage(debug.LEVEL_INFO, msg, True) self.liveRegionManager.handleEvent(event) return True @@ -3002,21 +3065,39 @@ class Script(default.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsEOCAdded(event): - msg = "WEB: Ignoring: Event was for embedded object char" + if reason == TextEventReason.CHILDREN_CHANGE: + msg = f"WEB: Ignoring event due to {reason}" debug.printMessage(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsBrowserUINoise(event): - msg = "WEB: Ignoring event believed to be browser UI noise" + # Preserve the broader legacy match for whitespace plus embedded + # object characters until the shared classifier owns that pattern. + if self.utilities.eventIsEOCAdded(event): + msg = "WEB: Ignoring: Event was for embedded object char" debug.printMessage(debug.LEVEL_INFO, msg, True) return True if not self.utilities.inDocumentContent(event.source): msg = "WEB: Event source is not in document content" debug.printMessage(debug.LEVEL_INFO, msg, True) + + if reason == TextEventReason.UI_UPDATE: + msg = f"WEB: Ignoring browser UI event due to {reason}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + if self.utilities.eventIsBrowserUINoise(event): + msg = "WEB: Ignoring event believed to be browser UI noise" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + return False + if reason == TextEventReason.SPIN_BUTTON_VALUE_CHANGE: + msg = f"WEB: Ignoring event due to {reason}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + if self.utilities.eventIsSpinnerNoise(event): msg = "WEB: Ignoring: Event believed to be spinner noise" debug.printMessage(debug.LEVEL_INFO, msg, True) @@ -3027,6 +3108,11 @@ class Script(default.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return True + if reason == TextEventReason.AUTO_INSERTION_PRESENTABLE: + msg = f"WEB: Deferring presentable event due to {reason}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return False + msg = "WEB: Clearing content cache due to text insertion" debug.printMessage(debug.LEVEL_INFO, msg, True) self.utilities.clearContentCache() diff --git a/src/cthulhu/scripts/web/script_utilities.py b/src/cthulhu/scripts/web/script_utilities.py index 714b132..8ec4d95 100644 --- a/src/cthulhu/scripts/web/script_utilities.py +++ b/src/cthulhu/scripts/web/script_utilities.py @@ -40,7 +40,6 @@ import time import urllib from cthulhu import debug -from cthulhu import input_event from cthulhu import input_event_manager from cthulhu import messages from cthulhu import cthulhu @@ -4682,32 +4681,6 @@ class Utilities(script_utilities.Utilities): debug.printMessage(debug.LEVEL_INFO, msg, True) return False - def textEventIsDueToDeletion(self, event): - if not self.inDocumentContent(event.source) \ - or not AXUtilities.is_editable(event.source): - return False - - if self.isDeleteCommandTextDeletionEvent(event) \ - or self.isBackSpaceCommandTextDeletionEvent(event): - return True - - return False - - def textEventIsDueToInsertion(self, event): - if not event.type.startswith("object:text-"): - return False - - if not self.inDocumentContent(event.source) \ - or event.source != cthulhu_state.locusOfFocus \ - or not AXUtilities.is_editable(event.source): - return False - - if isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent): - inputEvent = cthulhu_state.lastNonModifierKeyEvent - return inputEvent and inputEvent.is_printable_key() and not inputEvent.modifiers - - return False - def textEventIsForNonNavigableTextObject(self, event): if not event.type.startswith("object:text-"): return False diff --git a/tests/test_chromium_omnibox_regressions.py b/tests/test_chromium_omnibox_regressions.py index 81d38f7..20eff48 100644 --- a/tests/test_chromium_omnibox_regressions.py +++ b/tests/test_chromium_omnibox_regressions.py @@ -8,6 +8,9 @@ from gi.repository import Atspi from cthulhu import ax_object from cthulhu import cthulhu_state from cthulhu import focus_manager +from cthulhu.scripts import default +from cthulhu.scripts.toolkits.Chromium import script as chromium_script +from cthulhu.scripts.toolkits.Chromium import script_utilities as chromium_utilities class ChromiumOmniboxRegressionTests(unittest.TestCase): @@ -63,6 +66,186 @@ class ChromiumOmniboxRegressionTests(unittest.TestCase): self.assertIs(manager.get_active_window(), activeWindow) self.assertIs(cthulhu_state.activeWindow, activeWindow) + def test_detects_nameless_frame_with_listbox_as_autocomplete_popup(self): + utilities = chromium_utilities.Utilities.__new__(chromium_utilities.Utilities) + frame = object() + listbox = object() + + with ( + mock.patch.object(chromium_utilities.AXUtilities, "is_frame", return_value=True), + mock.patch.object(chromium_utilities.AXObject, "get_name", return_value=""), + mock.patch.object(chromium_utilities.AXObject, "get_child_count", return_value=1), + mock.patch.object(chromium_utilities.AXObject, "get_child", return_value=listbox), + mock.patch.object(chromium_utilities.AXUtilities, "is_list_box", return_value=True), + ): + result = utilities.autocompletePopupForFrame(frame) + + self.assertIs(result, listbox) + + def test_does_not_treat_nested_listbox_as_autocomplete_popup(self): + utilities = chromium_utilities.Utilities.__new__(chromium_utilities.Utilities) + frame = object() + + with ( + mock.patch.object(chromium_utilities.AXUtilities, "is_frame", return_value=True), + mock.patch.object(chromium_utilities.AXObject, "get_name", return_value=""), + mock.patch.object(chromium_utilities.AXObject, "get_child_count", return_value=2), + ): + result = utilities.autocompletePopupForFrame(frame) + + self.assertIsNone(result) + + @staticmethod + def _make_chromium_script(): + testScript = chromium_script.Script.__new__(chromium_script.Script) + testScript.utilities = mock.Mock() + testScript.utilities.canBeActiveWindow.return_value = True + testScript.utilities.autocompletePopupForFrame.return_value = None + testScript.utilities.popupMenuForFrame.return_value = None + testScript._lastAutocompletePopupItem = None + testScript.presentObject = mock.Mock() + return testScript + + def test_autocomplete_popup_activation_presents_item_without_frame_fallback(self): + testScript = self._make_chromium_script() + frame = object() + listbox = object() + item = object() + event = mock.Mock(source=frame) + testScript.utilities.autocompletePopupForFrame.return_value = listbox + testScript.utilities.selectedChildren.return_value = [item] + + with ( + mock.patch.object(chromium_script.AXUtilities, "get_focused_object", return_value=item), + mock.patch.object(chromium_script.cthulhu, "setActiveWindow") as setActiveWindow, + mock.patch.object(chromium_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus, + mock.patch.object(default.Script, "onWindowActivated") as defaultHandler, + ): + chromium_script.Script.onWindowActivated(testScript, event) + + setActiveWindow.assert_not_called() + setLocusOfFocus.assert_not_called() + testScript.presentObject.assert_called_once_with(item, interrupt=True) + self.assertIs(testScript._lastAutocompletePopupItem, item) + defaultHandler.assert_not_called() + + def test_autocomplete_popup_activation_uses_focused_item_when_none_selected(self): + testScript = self._make_chromium_script() + frame = object() + listbox = object() + item = object() + event = mock.Mock(source=frame) + testScript.utilities.autocompletePopupForFrame.return_value = listbox + testScript.utilities.selectedChildren.return_value = [] + + with mock.patch.object( + chromium_script.AXUtilities, + "get_focused_object", + return_value=item, + ): + chromium_script.Script.onWindowActivated(testScript, event) + + testScript.presentObject.assert_called_once_with(item, interrupt=True) + self.assertIs(testScript._lastAutocompletePopupItem, item) + + def test_autocomplete_popup_selection_is_presented_without_changing_document_focus(self): + testScript = self._make_chromium_script() + comboBox = object() + listbox = object() + frame = object() + item = object() + event = mock.Mock(source=item, detail1=True) + cthulhu_state.locusOfFocus = comboBox + testScript.utilities.inDocumentContent.return_value = False + + with ( + mock.patch.object(chromium_script.web.Script, "onSelectedChanged", return_value=False), + mock.patch.object( + chromium_script.AXObject, + "find_ancestor_inclusive", + return_value=listbox, + ), + mock.patch.object(chromium_script.AXObject, "get_parent", return_value=frame), + mock.patch.object(chromium_script.AXObject, "get_name", return_value=""), + mock.patch.object(chromium_script.AXUtilities, "is_frame", return_value=True), + ): + chromium_script.Script.onSelectedChanged(testScript, event) + chromium_script.Script.onSelectedChanged(testScript, event) + + self.assertIs(cthulhu_state.locusOfFocus, comboBox) + testScript.presentObject.assert_called_once_with(item, interrupt=True) + + def test_main_window_deactivation_for_autocomplete_popup_preserves_state(self): + testScript = self._make_chromium_script() + mainFrame = object() + comboBox = object() + event = mock.Mock(source=mainFrame) + cthulhu_state.locusOfFocus = comboBox + testScript.utilities.lastKeyAndModifiers.return_value = ("Down", 8) + + with ( + mock.patch.object(chromium_script.AXUtilities, "is_combo_box", return_value=True), + mock.patch.object( + chromium_script.AXUtilities, + "supports_autocompletion", + return_value=True, + ), + mock.patch.object(default.Script, "onWindowDeactivated") as defaultHandler, + ): + chromium_script.Script.onWindowDeactivated(testScript, event) + + defaultHandler.assert_not_called() + + def test_unrelated_autocomplete_window_deactivation_reaches_default(self): + testScript = self._make_chromium_script() + mainFrame = object() + comboBox = object() + app = object() + event = mock.Mock(source=mainFrame) + cthulhu_state.locusOfFocus = comboBox + testScript.utilities.lastKeyAndModifiers.return_value = ("w", 4) + + with ( + mock.patch.object(chromium_script.AXUtilities, "is_combo_box", return_value=True), + mock.patch.object( + chromium_script.AXUtilities, + "supports_autocompletion", + return_value=True, + ), + mock.patch.object(chromium_script.AXObject, "get_application", return_value=app), + mock.patch.object(chromium_script.AXObject, "iter_children", return_value=iter(())), + mock.patch.object(chromium_script.web.Script, "onWindowDeactivated", return_value=False), + mock.patch.object(default.Script, "onWindowDeactivated") as defaultHandler, + ): + chromium_script.Script.onWindowDeactivated(testScript, event) + + defaultHandler.assert_called_once_with(testScript, event) + + def test_unmodified_arrow_deactivation_reaches_default(self): + testScript = self._make_chromium_script() + mainFrame = object() + comboBox = object() + app = object() + event = mock.Mock(source=mainFrame) + cthulhu_state.locusOfFocus = comboBox + testScript.utilities.lastKeyAndModifiers.return_value = ("Down", 0) + + with ( + mock.patch.object(chromium_script.AXUtilities, "is_combo_box", return_value=True), + mock.patch.object( + chromium_script.AXUtilities, + "supports_autocompletion", + return_value=True, + ), + mock.patch.object(chromium_script.AXObject, "get_application", return_value=app), + mock.patch.object(chromium_script.AXObject, "iter_children", return_value=iter(())), + mock.patch.object(chromium_script.web.Script, "onWindowDeactivated", return_value=False), + mock.patch.object(default.Script, "onWindowDeactivated") as defaultHandler, + ): + chromium_script.Script.onWindowDeactivated(testScript, event) + + defaultHandler.assert_called_once_with(testScript, event) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_input_event_manager_key_watcher_regressions.py b/tests/test_input_event_manager_key_watcher_regressions.py index 5233464..81413fb 100644 --- a/tests/test_input_event_manager_key_watcher_regressions.py +++ b/tests/test_input_event_manager_key_watcher_regressions.py @@ -306,5 +306,25 @@ class InputEventManagerKeyWatcherTests(unittest.TestCase): [(modified, None), (unrelated, None)], ) + def test_keybinding_grab_failure_rolls_back_partial_additions(self) -> None: + manager = input_event_manager.InputEventManager() + device = mock.Mock() + device.add_key_grab.side_effect = [101, RuntimeError("grab failed")] + manager._device = device + firstDefinition = types.SimpleNamespace(keycode=44, modifiers=0) + secondDefinition = types.SimpleNamespace(keycode=45, modifiers=0) + binding = mock.Mock() + binding.is_enabled.return_value = True + binding.is_bound.return_value = True + binding.has_grabs.return_value = False + binding.keycode = 44 + binding.key_definitions.return_value = [firstDefinition, secondDefinition] + + with self.assertRaisesRegex(RuntimeError, "grab failed"): + manager.add_grabs_for_keybinding(binding) + + device.remove_key_grab.assert_called_once_with(101) + self.assertEqual(manager._grabbed_bindings, {}) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_input_event_manager_x11_focus_regressions.py b/tests/test_input_event_manager_x11_focus_regressions.py index f0b880f..e6bff40 100644 --- a/tests/test_input_event_manager_x11_focus_regressions.py +++ b/tests/test_input_event_manager_x11_focus_regressions.py @@ -525,6 +525,114 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase): oldScript.removeKeyGrabs.assert_called_once_with() oldScript.addKeyGrabs.assert_not_called() + def test_xterm_grabs_restore_on_x11_focus_change_without_keyboard_event(self): + manager = input_event_manager.InputEventManager() + activeScript = mock.Mock() + scriptManager = mock.Mock() + scriptManager.get_active_script.return_value = activeScript + screen = mock.Mock() + activeWindow = object() + screen.get_active_window.return_value = activeWindow + manager._scriptWithSuspendedGrabsForXterm = activeScript + + with ( + mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager), + mock.patch.object(manager, "_x11_window_xterm_match", return_value=False) as matcher, + mock.patch.object(input_event_manager.debug, "print_tokens"), + ): + manager._on_active_x11_window_changed(screen, None) + + matcher.assert_called_once_with(activeWindow) + activeScript.addKeyGrabs.assert_called_once_with() + self.assertIsNone(manager._scriptWithSuspendedGrabsForXterm) + + def test_xterm_focus_check_failure_starts_recovery_timer(self): + manager = input_event_manager.InputEventManager() + manager._device = object() + manager._scriptWithSuspendedGrabsForXterm = object() + screen = mock.Mock() + screen.get_active_window.side_effect = RuntimeError("focus unavailable") + + with ( + mock.patch.object(input_event_manager.GLib, "timeout_add", return_value=23), + mock.patch.object(input_event_manager.debug, "print_message"), + ): + manager._on_active_x11_window_changed(screen, None) + + self.assertEqual(manager._xtermRecoverySourceId, 23) + + def test_xterm_focus_monitor_is_connected_only_once(self): + manager = input_event_manager.InputEventManager() + screen = mock.Mock() + screen.connect.return_value = 17 + + manager._ensure_xterm_focus_monitor(screen) + manager._ensure_xterm_focus_monitor(screen) + + screen.connect.assert_called_once_with( + "active-window-changed", + manager._on_active_x11_window_changed, + ) + + def test_xterm_suspension_starts_fallback_when_focus_monitor_is_unavailable(self): + manager = input_event_manager.InputEventManager() + manager._device = object() + activeScript = mock.Mock() + scriptManager = mock.Mock() + scriptManager.get_active_script.return_value = activeScript + + with ( + mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager), + mock.patch.object(input_event_manager.GLib, "timeout_add", return_value=23) as timeoutAdd, + mock.patch.object(input_event_manager.debug, "print_tokens"), + ): + manager._suspend_key_grabs_for_xterm() + + timeoutAdd.assert_called_once_with(100, manager._poll_xterm_grab_recovery) + self.assertEqual(manager._xtermRecoverySourceId, 23) + + def test_xterm_grab_restore_failure_keeps_retry_state(self): + manager = input_event_manager.InputEventManager() + manager._device = object() + activeScript = mock.Mock() + activeScript.addKeyGrabs.side_effect = RuntimeError("grab failed") + scriptManager = mock.Mock() + scriptManager.get_active_script.return_value = activeScript + manager._scriptWithSuspendedGrabsForXterm = activeScript + + with ( + mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager), + mock.patch.object(input_event_manager.GLib, "timeout_add", return_value=23), + mock.patch.object(input_event_manager.debug, "print_message"), + mock.patch.object(input_event_manager.debug, "print_tokens"), + ): + manager._restore_key_grabs_after_xterm() + + self.assertIs(manager._scriptWithSuspendedGrabsForXterm, activeScript) + self.assertEqual(manager._xtermRecoverySourceId, 23) + activeScript.removeKeyGrabs.assert_called_once_with() + + def test_stopping_key_watcher_disconnects_xterm_focus_monitor(self): + manager = input_event_manager.InputEventManager() + screen = mock.Mock() + manager._xtermFocusScreen = screen + manager._xtermFocusHandlerId = 17 + manager._xtermRecoverySourceId = 23 + manager._scriptWithSuspendedGrabsForXterm = object() + + with ( + mock.patch.object(input_event_manager.GLib, "source_remove") as sourceRemove, + mock.patch.object(input_event_manager.debug, "print_message"), + ): + manager.stop_key_watcher() + + screen.disconnect.assert_called_once_with(17) + sourceRemove.assert_called_once_with(23) + self.assertIsNone(manager._xtermFocusScreen) + self.assertEqual(manager._xtermFocusHandlerId, 0) + self.assertEqual(manager._xtermRecoverySourceId, 0) + self.assertIsNone(manager._scriptWithSuspendedGrabsForXterm) + def test_identifier_is_xterm_matches_exact_xterm_only(self): self.assertTrue(input_event_manager.InputEventManager._identifier_is_xterm("xterm")) self.assertTrue(input_event_manager.InputEventManager._identifier_is_xterm("/usr/bin/xterm")) diff --git a/tests/test_web_input_regressions.py b/tests/test_web_input_regressions.py index eb0dac4..0ee84ce 100644 --- a/tests/test_web_input_regressions.py +++ b/tests/test_web_input_regressions.py @@ -347,8 +347,121 @@ class WebCaretMovedRegressionTests(unittest.TestCase): testScript.utilities.getCaretContext.return_value = ("source", 4) testScript.utilities.lastInputEventWasCaretNavWithSelection.return_value = False testScript.utilities.lastInputEventWasCharNav.return_value = True + testScript.utilities.inFindContainer.return_value = False + testScript.utilities.eventIsFromLocusOfFocusDocument.return_value = True + testScript.utilities.isItemForEditableComboBox.return_value = False + testScript.utilities.lastInputEventWasLineBoundaryNav.return_value = False + testScript.utilities.eventIsAutocompleteNoise.return_value = False + testScript.utilities.caretMovedOutsideActiveGrid.return_value = False + testScript.utilities.treatEventAsSpinnerValueChange.return_value = False + testScript._saveLastCursorPosition = mock.Mock() return testScript + def test_typing_reasons_update_cursor_for_editable_focus(self): + for reason in (TextEventReason.TYPING, TextEventReason.TYPING_ECHOABLE): + with self.subTest(reason=reason): + testScript = self._make_script() + source = object() + event = mock.Mock(type="object:text-caret-moved", source=source, detail1=5) + testScript.utilities.lastInputEventWasCharNav.return_value = False + testScript.utilities.textEventIsDueToInsertion.return_value = False + testScript.utilities.textEventIsDueToDeletion.return_value = False + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=reason, + ), + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", source), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True), + ): + result = web_script.Script.onCaretMoved(testScript, event) + + self.assertTrue(result) + testScript._saveLastCursorPosition.assert_called_once_with(source, 5) + testScript.utilities.textEventIsDueToInsertion.assert_not_called() + testScript.utilities.textEventIsDueToDeletion.assert_not_called() + testScript.utilities.isItemForEditableComboBox.assert_not_called() + + def test_typing_reason_from_non_focus_source_continues(self): + testScript = self._make_script() + source = object() + event = mock.Mock(type="object:text-caret-moved", source=source, detail1=5) + testScript.utilities.lastInputEventWasCharNav.return_value = False + testScript.utilities.textEventIsDueToInsertion.return_value = True + testScript.utilities.isItemForEditableComboBox.return_value = True + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.TYPING, + ), + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", object()), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True), + ): + result = web_script.Script.onCaretMoved(testScript, event) + + self.assertTrue(result) + testScript._saveLastCursorPosition.assert_not_called() + testScript.utilities.textEventIsDueToInsertion.assert_not_called() + testScript.utilities.textEventIsDueToDeletion.assert_not_called() + testScript.utilities.isItemForEditableComboBox.assert_called_once() + + def test_deletion_reasons_update_cursor_for_editable_source(self): + for reason in (TextEventReason.BACKSPACE, TextEventReason.DELETE): + with self.subTest(reason=reason): + testScript = self._make_script() + source = object() + event = mock.Mock(type="object:text-caret-moved", source=source, detail1=3) + testScript.utilities.lastInputEventWasCharNav.return_value = False + testScript.utilities.textEventIsDueToInsertion.return_value = False + testScript.utilities.textEventIsDueToDeletion.return_value = False + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=reason, + ), + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", object()), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True), + ): + result = web_script.Script.onCaretMoved(testScript, event) + + self.assertTrue(result) + testScript._saveLastCursorPosition.assert_called_once_with(source, 3) + testScript.utilities.textEventIsDueToInsertion.assert_not_called() + testScript.utilities.textEventIsDueToDeletion.assert_not_called() + testScript.utilities.isItemForEditableComboBox.assert_not_called() + + def test_deletion_reason_from_embedded_contenteditable_source_continues(self): + testScript = self._make_script() + source = object() + event = mock.Mock(type="object:text-caret-moved", source=source, detail1=3) + testScript.utilities.lastInputEventWasCharNav.return_value = False + testScript.utilities.textEventIsDueToInsertion.return_value = False + testScript.utilities.textEventIsDueToDeletion.return_value = True + testScript.utilities.isItemForEditableComboBox.return_value = True + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.DELETE, + ), + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", source), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=False), + ): + result = web_script.Script.onCaretMoved(testScript, event) + + self.assertTrue(result) + testScript._saveLastCursorPosition.assert_not_called() + testScript.utilities.textEventIsDueToInsertion.assert_not_called() + testScript.utilities.textEventIsDueToDeletion.assert_not_called() + testScript.utilities.isItemForEditableComboBox.assert_called_once() + def test_matching_char_nav_caret_event_is_consumed_without_duplicate_presentation(self): testScript = self._make_script() source = "source" @@ -396,6 +509,261 @@ class WebCaretMovedRegressionTests(unittest.TestCase): class TextEventReasonRegressionTests(unittest.TestCase): + @staticmethod + def _make_input_manager(): + manager = mock.Mock() + methods = ( + "last_event_was_page_switch", + "last_event_was_backspace", + "last_event_was_delete", + "last_event_was_cut", + "last_event_was_paste", + "last_event_was_undo", + "last_event_was_redo", + "last_event_was_command", + "last_event_was_space", + "last_event_was_tab", + "last_event_was_return", + "last_event_was_printable_key", + "last_event_was_middle_click", + "last_event_was_middle_release", + "last_event_was_up_or_down", + "last_event_was_page_up_or_page_down", + ) + for method in methods: + getattr(manager, method).return_value = False + return manager + + def _classify_insertion( + self, + event, + manager, + *, + role=Atspi.Role.ENTRY, + isLiveRegion=False, + isEditable=False, + spinAncestor=None, + ): + with ( + mock.patch("cthulhu.input_event_manager.get_manager", return_value=manager), + mock.patch.object( + web_script.AXObject, + "get_role", + return_value=role, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXUtilitiesRole.is_live_region", + return_value=isLiveRegion, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXUtilitiesState.is_editable", + return_value=isEditable, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXUtilitiesRole.is_terminal", + return_value=False, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXUtilitiesRole.is_spin_button", + return_value=False, + ), + mock.patch.object( + web_script.AXObject, + "find_ancestor", + return_value=spinAncestor, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXText.get_selected_text", + return_value=("", 0, 0), + ), + ): + return AXUtilitiesEvent._get_text_insertion_event_reason(event) + + def _classify_deletion( + self, + event, + manager, + *, + role=Atspi.Role.ENTRY, + isLiveRegion=False, + isEditable=False, + spinAncestor=None, + selectedText="", + ): + with ( + mock.patch("cthulhu.input_event_manager.get_manager", return_value=manager), + mock.patch.object( + web_script.AXObject, + "get_role", + return_value=role, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXUtilitiesRole.is_live_region", + return_value=isLiveRegion, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXUtilitiesState.is_editable", + return_value=isEditable, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXUtilitiesRole.is_terminal", + return_value=False, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXUtilitiesRole.is_spin_button", + return_value=False, + ), + mock.patch.object( + web_script.AXObject, + "find_ancestor", + return_value=spinAncestor, + ), + mock.patch( + "cthulhu.ax_utilities_event.AXText.get_cached_selected_text", + return_value=(selectedText, 0, len(selectedText)), + ), + ): + return AXUtilitiesEvent._get_text_deletion_event_reason(event) + + def test_insertion_classifier_covers_initial_handler_reasons(self): + cases = ( + ("ui update", TextEventReason.UI_UPDATE, {"role": Atspi.Role.LABEL}, {}), + ( + "live region", + TextEventReason.LIVE_REGION_UPDATE, + {"isLiveRegion": True}, + {}, + ), + ( + "page switch", + TextEventReason.PAGE_SWITCH, + {}, + {"last_event_was_page_switch": True}, + ), + ( + "children change", + TextEventReason.CHILDREN_CHANGE, + {}, + {}, + ), + ( + "spin button", + TextEventReason.SPIN_BUTTON_VALUE_CHANGE, + {"isEditable": True, "spinAncestor": object()}, + {"last_event_was_up_or_down": True}, + ), + ( + "automatic insertion", + TextEventReason.AUTO_INSERTION_PRESENTABLE, + {"isEditable": True}, + {"last_event_was_up_or_down": True}, + ), + ) + + for name, expected, classifierKwargs, managerResults in cases: + with self.subTest(name=name): + manager = self._make_input_manager() + for method, result in managerResults.items(): + getattr(manager, method).return_value = result + anyData = "\ufffc" if expected == TextEventReason.CHILDREN_CHANGE else "x" + event = mock.Mock( + type="object:text-changed:insert", + source=object(), + any_data=anyData, + ) + + reason = self._classify_insertion( + event, + manager, + **classifierKwargs, + ) + + self.assertEqual(reason, expected) + + def test_deletion_classifier_covers_initial_handler_reasons(self): + cases = ( + ("ui update", TextEventReason.UI_UPDATE, {"role": Atspi.Role.LABEL}, {}), + ( + "live region", + TextEventReason.LIVE_REGION_UPDATE, + {"isLiveRegion": True}, + {}, + ), + ( + "page switch", + TextEventReason.PAGE_SWITCH, + {}, + {"last_event_was_page_switch": True}, + ), + ( + "backspace", + TextEventReason.BACKSPACE, + {"isEditable": True}, + {"last_event_was_backspace": True}, + ), + ( + "delete", + TextEventReason.DELETE, + {"isEditable": True}, + {"last_event_was_delete": True}, + ), + ( + "typing", + TextEventReason.TYPING, + {"isEditable": True}, + {"last_event_was_printable_key": True}, + ), + ( + "spin button", + TextEventReason.SPIN_BUTTON_VALUE_CHANGE, + {"isEditable": True, "spinAncestor": object()}, + {"last_event_was_up_or_down": True}, + ), + ( + "automatic deletion", + TextEventReason.AUTO_DELETION, + {"isEditable": True}, + {"last_event_was_up_or_down": True}, + ), + ( + "selected text", + TextEventReason.SELECTED_TEXT_DELETION, + {"isEditable": True, "selectedText": "selected"}, + {}, + ), + ( + "children change", + TextEventReason.CHILDREN_CHANGE, + {}, + {}, + ), + ) + + for name, expected, classifierKwargs, managerResults in cases: + with self.subTest(name=name): + manager = self._make_input_manager() + for method, result in managerResults.items(): + getattr(manager, method).return_value = result + if expected == TextEventReason.CHILDREN_CHANGE: + anyData = "\ufffc" + elif expected == TextEventReason.SELECTED_TEXT_DELETION: + anyData = "selected" + else: + anyData = "x" + event = mock.Mock( + type="object:text-changed:delete", + source=object(), + any_data=anyData, + ) + + reason = self._classify_deletion( + event, + manager, + **classifierKwargs, + ) + + self.assertEqual(reason, expected) + def test_tab_is_classified_as_focus_change_before_editable_state(self): oldFocus = object() source = object() @@ -434,6 +802,632 @@ class TextEventReasonRegressionTests(unittest.TestCase): isEditable.assert_not_called() +class WebTextInsertedReasonRegressionTests(unittest.TestCase): + @staticmethod + def _make_script(): + testScript = web_script.Script.__new__(web_script.Script) + testScript.utilities = mock.Mock() + testScript.utilities.isZombie.return_value = False + testScript.utilities.lastInputEventWasPageSwitch.return_value = False + testScript.utilities.handleAsLiveRegion.return_value = False + testScript.utilities.isLiveRegion.return_value = False + testScript.utilities.eventIsEOCAdded.return_value = False + testScript.utilities.eventIsBrowserUINoise.return_value = False + testScript.utilities.inDocumentContent.return_value = True + testScript.utilities.eventIsSpinnerNoise.return_value = False + testScript.utilities.eventIsAutocompleteNoise.return_value = False + testScript.utilities.getTopLevelDocumentForObject.return_value = "document" + testScript.utilities.queryNonEmptyText.return_value = "text" + testScript.utilities.isClickableElement.return_value = False + testScript.liveRegionManager = mock.Mock() + testScript.structuralNavigation = mock.Mock() + return testScript + + def test_page_switch_reason_owns_insertion(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:insert", source=object(), any_data="x") + testScript.utilities.eventIsAutocompleteNoise.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.PAGE_SWITCH, + ): + result = web_script.Script.onTextInserted(testScript, event) + + self.assertTrue(result) + testScript.utilities.lastInputEventWasPageSwitch.assert_not_called() + testScript.utilities.clearContentCache.assert_not_called() + + def test_page_switch_fallback_precedes_overlapping_live_region_reason(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:insert", source=object(), any_data="x") + testScript.utilities.lastInputEventWasPageSwitch.return_value = True + testScript.utilities.handleAsLiveRegion.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.LIVE_REGION_UPDATE, + ): + result = web_script.Script.onTextInserted(testScript, event) + + self.assertTrue(result) + testScript.utilities.lastInputEventWasPageSwitch.assert_called_once() + testScript.utilities.handleAsLiveRegion.assert_not_called() + testScript.liveRegionManager.handleEvent.assert_not_called() + + def test_live_region_reason_selects_live_region_owner(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:insert", source=object(), any_data="x") + testScript.utilities.handleAsLiveRegion.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.LIVE_REGION_UPDATE, + ): + result = web_script.Script.onTextInserted(testScript, event) + + self.assertTrue(result) + testScript.liveRegionManager.handleEvent.assert_called_once_with(event) + testScript.utilities.isLiveRegion.assert_not_called() + testScript.utilities.clearContentCache.assert_not_called() + + def test_unpresented_live_region_reason_is_consumed(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:insert", source=object(), any_data="x") + testScript.utilities.isLiveRegion.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.LIVE_REGION_UPDATE, + ): + result = web_script.Script.onTextInserted(testScript, event) + + self.assertTrue(result) + testScript.liveRegionManager.handleEvent.assert_not_called() + testScript.utilities.isLiveRegion.assert_not_called() + testScript.utilities.clearContentCache.assert_not_called() + + def test_unknown_reason_uses_legacy_live_region_fallback(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:insert", source=object(), any_data="x") + testScript.utilities.handleAsLiveRegion.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.UNKNOWN, + ): + result = web_script.Script.onTextInserted(testScript, event) + + self.assertTrue(result) + testScript.liveRegionManager.handleEvent.assert_called_once_with(event) + + def test_children_change_reason_owns_insertion(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:insert", source=object(), any_data="\ufffc") + testScript.utilities.eventIsAutocompleteNoise.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.CHILDREN_CHANGE, + ): + result = web_script.Script.onTextInserted(testScript, event) + + self.assertTrue(result) + testScript.utilities.eventIsEOCAdded.assert_not_called() + testScript.utilities.clearContentCache.assert_not_called() + + def test_unknown_reason_uses_legacy_embedded_object_fallback(self): + testScript = self._make_script() + event = mock.Mock( + type="object:text-changed:insert", + source=object(), + any_data=" \ufffc", + ) + testScript.utilities.eventIsEOCAdded.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.UNKNOWN, + ): + result = web_script.Script.onTextInserted(testScript, event) + + self.assertTrue(result) + testScript.utilities.eventIsEOCAdded.assert_called_once_with(event) + testScript.utilities.clearContentCache.assert_not_called() + + def test_ui_update_reason_owns_insertion_outside_document(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:insert", source=object(), any_data="x") + testScript.utilities.inDocumentContent.return_value = False + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.UI_UPDATE, + ): + result = web_script.Script.onTextInserted(testScript, event) + + self.assertTrue(result) + testScript.utilities.eventIsBrowserUINoise.assert_not_called() + testScript.utilities.clearContentCache.assert_not_called() + + def test_spin_button_reason_owns_insertion(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:insert", source=object(), any_data="1") + testScript.utilities.eventIsAutocompleteNoise.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.SPIN_BUTTON_VALUE_CHANGE, + ): + result = web_script.Script.onTextInserted(testScript, event) + + self.assertTrue(result) + testScript.utilities.eventIsSpinnerNoise.assert_not_called() + testScript.utilities.clearContentCache.assert_not_called() + + def test_presentable_automatic_insertion_reaches_default_owner(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:insert", source=object(), any_data="choice") + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.AUTO_INSERTION_PRESENTABLE, + ): + result = web_script.Script.onTextInserted(testScript, event) + + self.assertFalse(result) + testScript.utilities.eventIsSpinnerNoise.assert_called_once_with(event) + testScript.utilities.eventIsAutocompleteNoise.assert_called_once_with(event) + testScript.utilities.clearContentCache.assert_not_called() + + def test_autocomplete_noise_precedes_presentable_automatic_insertion(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:insert", source=object(), any_data="choice") + testScript.utilities.eventIsAutocompleteNoise.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.AUTO_INSERTION_PRESENTABLE, + ): + result = web_script.Script.onTextInserted(testScript, event) + + self.assertTrue(result) + testScript.utilities.eventIsAutocompleteNoise.assert_called_once_with(event) + testScript.utilities.clearContentCache.assert_not_called() + + +class WebTextDeletedReasonRegressionTests(unittest.TestCase): + @staticmethod + def _make_script(): + testScript = web_script.Script.__new__(web_script.Script) + testScript.utilities = mock.Mock() + testScript.utilities.isZombie.return_value = False + testScript.utilities.lastInputEventWasPageSwitch.return_value = False + testScript.utilities.isLiveRegion.return_value = False + testScript.utilities.eventIsBrowserUINoise.return_value = False + testScript.utilities.inDocumentContent.return_value = True + testScript.utilities.eventIsSpinnerNoise.return_value = False + testScript.utilities.eventIsAutocompleteNoise.return_value = False + testScript.utilities.textEventIsDueToDeletion.return_value = False + testScript.utilities.textEventIsDueToInsertion.return_value = False + testScript.utilities.isHidden.return_value = False + testScript.utilities.getCaretContext.return_value = (None, -1) + testScript.utilities.getDocumentForObject.return_value = None + testScript.utilities.isLink.return_value = False + testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = True + testScript._inMouseOverObject = False + testScript._lastMouseOverObject = None + testScript.restorePreMouseOverContext = mock.Mock() + testScript.structuralNavigation = mock.Mock() + return testScript + + def test_page_switch_reason_owns_deletion(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:delete", source=object(), any_data="x") + testScript.utilities.eventIsAutocompleteNoise.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.PAGE_SWITCH, + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertTrue(result) + testScript.utilities.lastInputEventWasPageSwitch.assert_not_called() + testScript.utilities.clearContentCache.assert_not_called() + + def test_page_switch_fallback_precedes_overlapping_live_region_reason(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:delete", source=object(), any_data="x") + testScript.utilities.lastInputEventWasPageSwitch.return_value = True + testScript.utilities.isLiveRegion.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.LIVE_REGION_UPDATE, + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertTrue(result) + testScript.utilities.lastInputEventWasPageSwitch.assert_called_once() + testScript.utilities.isLiveRegion.assert_not_called() + testScript.utilities.clearContentCache.assert_not_called() + + def test_live_region_reason_owns_deletion(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:delete", source=object(), any_data="x") + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.LIVE_REGION_UPDATE, + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertTrue(result) + testScript.utilities.isLiveRegion.assert_not_called() + testScript.utilities.clearContentCache.assert_not_called() + + def test_unknown_reason_uses_legacy_live_region_fallback(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:delete", source=object(), any_data="x") + testScript.utilities.isLiveRegion.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.UNKNOWN, + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertTrue(result) + testScript.utilities.isLiveRegion.assert_called_once_with(event.source) + testScript.utilities.clearContentCache.assert_not_called() + + def test_ui_update_reason_owns_deletion_outside_document(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:delete", source=object(), any_data="x") + testScript.utilities.inDocumentContent.return_value = False + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.UI_UPDATE, + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertTrue(result) + testScript.utilities.eventIsBrowserUINoise.assert_not_called() + testScript.utilities.clearContentCache.assert_not_called() + + def test_unknown_browser_ui_noise_uses_legacy_fallback(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:delete", source=object(), any_data="x") + testScript.utilities.inDocumentContent.return_value = False + testScript.utilities.eventIsBrowserUINoise.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.UNKNOWN, + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertTrue(result) + testScript.utilities.eventIsBrowserUINoise.assert_called_once_with(event) + testScript.utilities.clearContentCache.assert_not_called() + + def test_spin_button_reason_owns_deletion(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:delete", source=object(), any_data="1") + testScript.utilities.eventIsAutocompleteNoise.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.SPIN_BUTTON_VALUE_CHANGE, + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertTrue(result) + testScript.utilities.eventIsSpinnerNoise.assert_not_called() + testScript.utilities.eventIsAutocompleteNoise.assert_not_called() + testScript.utilities.clearContentCache.assert_not_called() + + def test_autocomplete_noise_precedes_automatic_deletion(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:delete", source=object(), any_data="choice") + testScript.utilities.eventIsAutocompleteNoise.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.AUTO_DELETION, + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertTrue(result) + testScript.utilities.eventIsSpinnerNoise.assert_called_once_with(event) + testScript.utilities.eventIsAutocompleteNoise.assert_called_once_with(event) + testScript.utilities.clearContentCache.assert_not_called() + + def test_unknown_reason_uses_legacy_spinner_fallback(self): + testScript = self._make_script() + event = mock.Mock(type="object:text-changed:delete", source=object(), any_data="1") + testScript.utilities.eventIsSpinnerNoise.return_value = True + + with mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.UNKNOWN, + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertTrue(result) + testScript.utilities.eventIsSpinnerNoise.assert_called_once_with(event) + testScript.utilities.eventIsAutocompleteNoise.assert_not_called() + testScript.utilities.clearContentCache.assert_not_called() + + def test_automatic_deletion_keeps_legacy_caret_relevance_path(self): + testScript = self._make_script() + source = object() + event = mock.Mock(type="object:text-changed:delete", source=source, any_data="choice") + testScript.utilities.getCaretContext.return_value = (source, 0) + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.AUTO_DELETION, + ), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True), + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertFalse(result) + testScript.utilities.clearContentCache.assert_called_once() + testScript.utilities.getCaretContext.assert_called_once_with(getZombieReplicant=False) + + def test_editable_deletion_reasons_reach_default_owner(self): + for reason in (TextEventReason.BACKSPACE, TextEventReason.DELETE): + with self.subTest(reason=reason): + testScript = self._make_script() + source = object() + event = mock.Mock( + type="object:text-changed:delete", + source=source, + any_data="x", + ) + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=reason, + ), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True), + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertFalse(result) + testScript.utilities.clearContentCache.assert_called_once() + testScript.utilities.textEventIsDueToDeletion.assert_not_called() + testScript.utilities.textEventIsDueToInsertion.assert_not_called() + testScript.utilities.getCaretContext.assert_not_called() + + def test_hidden_backspace_reason_keeps_caret_relevance_path(self): + testScript = self._make_script() + source = object() + event = mock.Mock(type="object:text-changed:delete", source=source, any_data="x") + testScript.utilities.isHidden.return_value = True + testScript.utilities.getCaretContext.return_value = (source, 0) + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.BACKSPACE, + ), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True), + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertFalse(result) + testScript.utilities.getCaretContext.assert_called_once_with(getZombieReplicant=False) + + def test_hidden_delete_reason_reaches_default_owner(self): + testScript = self._make_script() + source = object() + event = mock.Mock(type="object:text-changed:delete", source=source, any_data="x") + testScript.utilities.isHidden.return_value = True + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.DELETE, + ), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True), + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertFalse(result) + testScript.utilities.getCaretContext.assert_not_called() + + def test_deletion_reason_from_non_editable_source_continues(self): + testScript = self._make_script() + source = object() + event = mock.Mock(type="object:text-changed:delete", source=source, any_data="x") + testScript.utilities.getCaretContext.return_value = (source, 0) + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.DELETE, + ), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=False), + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertFalse(result) + testScript.utilities.textEventIsDueToDeletion.assert_not_called() + testScript.utilities.textEventIsDueToInsertion.assert_not_called() + testScript.utilities.getCaretContext.assert_called_once_with(getZombieReplicant=False) + + def test_deletion_reason_from_ordinary_non_editable_source_is_consumed_late(self): + testScript = self._make_script() + source = object() + event = mock.Mock(type="object:text-changed:delete", source=source, any_data="x") + testScript.utilities.getCaretContext.return_value = (source, 0) + testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = False + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.DELETE, + ), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=False), + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertTrue(result) + testScript.utilities.getCaretContext.assert_called_once_with(getZombieReplicant=False) + + def test_typing_reason_owns_insertion_caused_deletion_for_editable_focus(self): + testScript = self._make_script() + source = object() + event = mock.Mock(type="object:text-changed:delete", source=source, any_data="x") + keyboardEvent = mock.Mock(spec=web_script.input_event.KeyboardEvent) + nonModifierEvent = mock.Mock(modifiers=0) + nonModifierEvent.is_printable_key.return_value = True + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.TYPING, + ), + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", source), + mock.patch.object(web_script.cthulhu_state, "lastInputEvent", keyboardEvent), + mock.patch.object( + web_script.cthulhu_state, + "lastNonModifierKeyEvent", + nonModifierEvent, + ), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True), + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertTrue(result) + testScript.utilities.clearContentCache.assert_called_once() + testScript.utilities.textEventIsDueToDeletion.assert_not_called() + testScript.utilities.textEventIsDueToInsertion.assert_not_called() + testScript.utilities.getCaretContext.assert_not_called() + + def test_modified_typing_reason_keeps_caret_relevance_path(self): + testScript = self._make_script() + source = object() + event = mock.Mock(type="object:text-changed:delete", source=source, any_data="x") + testScript.utilities.getCaretContext.return_value = (source, 0) + keyboardEvent = mock.Mock(spec=web_script.input_event.KeyboardEvent) + nonModifierEvent = mock.Mock(modifiers=Atspi.ModifierType.ALT) + nonModifierEvent.is_printable_key.return_value = True + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.TYPING, + ), + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", source), + mock.patch.object(web_script.cthulhu_state, "lastInputEvent", keyboardEvent), + mock.patch.object( + web_script.cthulhu_state, + "lastNonModifierKeyEvent", + nonModifierEvent, + ), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True), + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertFalse(result) + testScript.utilities.getCaretContext.assert_called_once_with(getZombieReplicant=False) + + def test_typing_reason_from_non_focus_source_continues(self): + testScript = self._make_script() + source = object() + event = mock.Mock(type="object:text-changed:delete", source=source, any_data="x") + testScript.utilities.getCaretContext.return_value = (source, 0) + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.TYPING, + ), + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", object()), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True), + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertFalse(result) + testScript.utilities.textEventIsDueToDeletion.assert_not_called() + testScript.utilities.textEventIsDueToInsertion.assert_not_called() + testScript.utilities.getCaretContext.assert_called_once_with(getZombieReplicant=False) + + def test_unknown_reason_preserves_zombie_caret_context_recovery(self): + testScript = self._make_script() + source = object() + zombie = object() + replacement = object() + document = object() + event = mock.Mock(type="object:text-changed:delete", source=source, any_data="x") + testScript.utilities.isZombie.side_effect = lambda obj: obj is zombie + testScript.utilities.getCaretContext.side_effect = ((zombie, 2), (replacement, 3)) + testScript.utilities.getDocumentForObject.return_value = document + + with ( + mock.patch.object( + AXUtilitiesEvent, + "get_text_event_reason", + return_value=TextEventReason.UNKNOWN, + ), + mock.patch.object(web_script.AXObject, "find_ancestor", return_value=source), + mock.patch.object(web_script.AXUtilities, "is_editable", return_value=True), + mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus, + ): + result = web_script.Script.onTextDeleted(testScript, event) + + self.assertFalse(result) + testScript.utilities.clearContentCache.assert_called_once() + testScript.utilities.textEventIsDueToDeletion.assert_not_called() + testScript.utilities.textEventIsDueToInsertion.assert_not_called() + self.assertEqual( + testScript.utilities.getCaretContext.call_args_list, + [ + mock.call(getZombieReplicant=False), + mock.call(getZombieReplicant=True), + ], + ) + setLocusOfFocus.assert_called_once_with(event, replacement, notifyScript=False) + testScript.structuralNavigation.clearCache.assert_called_once_with(document) + + class WebDescriptionChangeRegressionTests(unittest.TestCase): def _make_script(self): testScript = web_script.Script.__new__(web_script.Script)