"""Ordered browser event-sequence regression tests.""" import unittest from dataclasses import dataclass from typing import Any from unittest import mock import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi from cthulhu import cthulhu_state from cthulhu.ax_utilities_event import AXUtilitiesEvent, TextEventReason from cthulhu.scripts import default from cthulhu.scripts.toolkits.Chromium import script as chromium_script from cthulhu.scripts.toolkits.Gecko import script as gecko_script from cthulhu.scripts.web import script as web_script @dataclass(eq=False) class FakeAccessible: """Minimal accessible metadata needed to describe a sequence target.""" role: Atspi.Role editable: bool = False @dataclass(eq=False) class FakeEvent: """Minimal AT-SPI event used by ordered web regression fixtures.""" type: str source: FakeAccessible detail1: int = 0 any_data: FakeAccessible | None = None class WebEventSequence: """Dispatch an ordered event list through Chromium's current routing path.""" def __init__(self, testScript: chromium_script.Script) -> None: self.testScript = testScript def run(self, events: list[FakeEvent]) -> None: handlers = { "object:active-descendant-changed": chromium_script.Script.onActiveDescendantChanged, "object:children-changed:add": chromium_script.Script.onChildrenAdded, "object:children-changed:remove": chromium_script.Script.onChildrenRemoved, "object:selection-changed": chromium_script.Script.onSelectionChanged, "object:state-changed:busy": chromium_script.Script.onBusyChanged, "object:text-caret-moved": chromium_script.Script.onCaretMoved, "object:text-selection-changed": chromium_script.Script.onTextSelectionChanged, "object:state-changed:focused": chromium_script.Script.onFocusedChanged, "document:load-complete": chromium_script.Script.onDocumentLoadComplete, "document:reload": chromium_script.Script.onDocumentReload, } for event in events: handler = handlers[event.type] handler(self.testScript, event) class GeckoEventSequence: """Dispatch an ordered event list through Gecko's current routing path.""" def __init__(self, testScript: gecko_script.Script) -> None: self.testScript = testScript def run(self, events: list[FakeEvent]) -> None: handlers = { "object:text-caret-moved": gecko_script.Script.onCaretMoved, "object:state-changed:focused": gecko_script.Script.onFocusedChanged, } for event in events: handler = handlers[event.type] handler(self.testScript, event) class ChromiumTabFocusSequenceTests(unittest.TestCase): """Behavior contracts for controls reached by Tab in Chromium.""" @staticmethod def _make_script(document: object, oldFocus: object) -> chromium_script.Script: testScript = chromium_script.Script.__new__(chromium_script.Script) testScript._lastCommandWasCaretNav = False testScript._lastCommandWasStructNav = False testScript._lastCommandWasMouseButton = False testScript._clearSyntheticWebSelection = mock.Mock() testScript.utilities = mock.Mock() testScript.utilities.isStaticTextLeaf.return_value = False testScript.utilities.isRedundantAutocompleteEvent.return_value = False testScript.utilities.isZombie.return_value = False testScript.utilities.getTopLevelDocumentForObject.return_value = document testScript.utilities.getCaretContext.return_value = (oldFocus, 0) testScript.utilities.lastInputEventWasCaretNavWithSelection.return_value = False testScript.utilities.lastInputEventWasCharNav.return_value = False testScript.utilities.getDocumentForObject.return_value = document testScript.utilities.isWebAppDescendant.return_value = False testScript.utilities.handleEventFromContextReplicant.return_value = False return testScript def _assert_tab_caret_focused_sequence_presents_once( self, target: FakeAccessible, ) -> None: document = object() oldFocus = object() testScript = self._make_script(document, oldFocus) inputManager = mock.Mock() inputManager.last_event_was_caret_selection.return_value = False inputManager.last_event_was_caret_navigation.return_value = False inputManager.last_event_was_select_all.return_value = False inputManager.last_event_was_primary_click_or_release.return_value = False inputManager.last_event_was_tab_navigation.return_value = True presentations: list[Any] = [] events = [ FakeEvent("object:text-caret-moved", target, detail1=0), FakeEvent("object:state-changed:focused", target, detail1=1), ] with ( mock.patch.object(cthulhu_state, "locusOfFocus", oldFocus), mock.patch( "cthulhu.input_event_manager.get_manager", return_value=inputManager, ), mock.patch( "cthulhu.ax_utilities_event.focus_manager.get_manager" ) as getFocusManager, mock.patch( "cthulhu.ax_utilities_event.AXUtilitiesRole.is_text_input_search", return_value=False, ), mock.patch( "cthulhu.ax_utilities_event.AXUtilitiesState.is_editable", side_effect=lambda obj: obj.editable, ), mock.patch.object( web_script.AXUtilities, "is_editable", side_effect=lambda obj: obj.editable, ), mock.patch.object(web_script.AXUtilities, "is_dialog_or_alert", return_value=False), mock.patch.object(web_script.AXUtilities, "is_focusable", return_value=True), mock.patch.object(web_script.AXUtilities, "is_focused", return_value=True), mock.patch.object(web_script.AXUtilities, "is_document", return_value=False), mock.patch.object( default.Script, "onCaretMoved", side_effect=lambda _script, event: presentations.append(event.source), ) as defaultCaretHandler, mock.patch.object( default.Script, "onFocusedChanged", side_effect=lambda _script, event: presentations.append(event.source), ) as defaultFocusHandler, ): getFocusManager.return_value.get_active_mode_and_object_of_interest.return_value = ( None, oldFocus, ) WebEventSequence(testScript).run(events) self.assertEqual(presentations, [target]) defaultCaretHandler.assert_not_called() defaultFocusHandler.assert_called_once_with(testScript, events[1]) testScript._clearSyntheticWebSelection.assert_called_once_with() def test_tab_caret_focused_sequence_presents_editable_custom_button_once(self) -> None: customButton = FakeAccessible(Atspi.Role.PUSH_BUTTON, editable=True) self._assert_tab_caret_focused_sequence_presents_once(customButton) def test_tab_caret_focused_sequence_presents_native_entry_once(self) -> None: entry = FakeAccessible(Atspi.Role.ENTRY, editable=True) self._assert_tab_caret_focused_sequence_presents_once(entry) def test_tab_caret_focused_sequence_presents_native_checkbox_once(self) -> None: checkbox = FakeAccessible(Atspi.Role.CHECK_BOX) self._assert_tab_caret_focused_sequence_presents_once(checkbox) def test_tab_caret_focused_sequence_presents_native_radio_once(self) -> None: radio = FakeAccessible(Atspi.Role.RADIO_BUTTON) self._assert_tab_caret_focused_sequence_presents_once(radio) class FocusedChangeInteractionSequenceTests(unittest.TestCase): """Ordered ownership contracts around focused-change events.""" @staticmethod def _make_script( document: object, caretContext: FakeAccessible, ) -> chromium_script.Script: testScript = chromium_script.Script.__new__(chromium_script.Script) testScript._lastCommandWasCaretNav = False testScript._lastCommandWasStructNav = False testScript._lastCommandWasMouseButton = False testScript._browseModeIsSticky = False testScript._clearSyntheticWebSelection = mock.Mock() testScript.refreshKeyGrabs = mock.Mock() testScript.utilities = mock.Mock() testScript.utilities.isDocument.return_value = False testScript.utilities.isStaticTextLeaf.return_value = False testScript.utilities.isRedundantAutocompleteEvent.return_value = False testScript.utilities.isZombie.return_value = False testScript.utilities.getTopLevelDocumentForObject.return_value = document testScript.utilities.getDocumentForObject.return_value = document testScript.utilities.getCaretContext.return_value = (caretContext, 0) testScript.utilities.lastInputEventWasCaretNavWithSelection.return_value = False testScript.utilities.lastInputEventWasCharNav.return_value = False testScript.utilities.inFindContainer.return_value = False testScript.utilities.eventIsFromLocusOfFocusDocument.return_value = True testScript.utilities.isWebAppDescendant.return_value = False testScript.utilities.handleEventFromContextReplicant.return_value = False testScript.utilities.lastInputEventWasPageNav.return_value = False testScript.utilities.isAnchor.return_value = False testScript.utilities.isLink.return_value = False testScript.utilities.isChildOfCurrentFragment.return_value = False testScript.utilities.documentFragment.return_value = "" return testScript def _assert_navigation_command_owns_caret_and_focus_pair( self, *, caretNavigation: bool, structuralNavigation: bool, ) -> None: document = object() target = FakeAccessible(Atspi.Role.PARAGRAPH) testScript = self._make_script(document, target) testScript._lastCommandWasCaretNav = caretNavigation testScript._lastCommandWasStructNav = structuralNavigation events = [ FakeEvent("object:text-caret-moved", target, detail1=4), FakeEvent("object:state-changed:focused", target, detail1=1), ] presentations = ["navigation-command"] with ( mock.patch.object(cthulhu_state, "locusOfFocus", target), mock.patch.object( AXUtilitiesEvent, "get_text_event_reason", return_value=TextEventReason.NAVIGATION_BY_CHARACTER, ), mock.patch.object(web_script.AXUtilities, "is_editable", return_value=False), mock.patch.object(web_script.AXUtilities, "is_dialog_or_alert", return_value=False), mock.patch.object(web_script.AXUtilities, "is_focusable", return_value=True), mock.patch.object(web_script.AXUtilities, "is_focused", return_value=True), mock.patch.object(web_script.AXUtilities, "is_document", return_value=False), mock.patch.object( default.Script, "onCaretMoved", side_effect=lambda _script, _event: presentations.append("caret-event"), ) as defaultCaretHandler, mock.patch.object( default.Script, "onFocusedChanged", side_effect=lambda _script, _event: presentations.append("focus-event"), ) as defaultFocusHandler, ): WebEventSequence(testScript).run(events) self.assertEqual(presentations, ["navigation-command"]) defaultCaretHandler.assert_not_called() defaultFocusHandler.assert_not_called() def test_caret_navigation_owns_following_caret_and_focus_events(self) -> None: self._assert_navigation_command_owns_caret_and_focus_pair( caretNavigation=True, structuralNavigation=False, ) def test_structural_navigation_owns_following_caret_and_focus_events(self) -> None: self._assert_navigation_command_owns_caret_and_focus_pair( caretNavigation=False, structuralNavigation=True, ) def test_tab_clears_navigation_ownership_and_real_focus_still_presents(self) -> None: document = object() oldTarget = FakeAccessible(Atspi.Role.PARAGRAPH) newTarget = FakeAccessible(Atspi.Role.ENTRY, editable=True) testScript = self._make_script(document, oldTarget) testScript._lastCommandWasCaretNav = True ignoredFocus = FakeEvent( "object:state-changed:focused", oldTarget, detail1=1, ) realFocus = FakeEvent( "object:state-changed:focused", newTarget, detail1=1, ) tabEvent = mock.Mock(event_string="Tab") tabEvent.is_modifier_key.return_value = False presentations: list[FakeAccessible] = [] with ( mock.patch.object(cthulhu_state, "locusOfFocus", oldTarget), mock.patch.object( web_script.AXUtilities, "is_editable", side_effect=lambda obj: obj.editable, ), mock.patch.object(web_script.AXUtilities, "is_dialog_or_alert", return_value=False), mock.patch.object(web_script.AXUtilities, "is_focusable", return_value=True), mock.patch.object(web_script.AXUtilities, "is_focused", return_value=True), mock.patch.object(web_script.AXUtilities, "is_document", return_value=False), mock.patch.object(default.Script, "shouldConsumeKeyboardEvent", return_value=False), mock.patch.object( default.Script, "onFocusedChanged", side_effect=lambda _script, event: presentations.append(event.source), ) as defaultFocusHandler, ): WebEventSequence(testScript).run([ignoredFocus]) web_script.Script.shouldConsumeKeyboardEvent(testScript, tabEvent, None) WebEventSequence(testScript).run([realFocus]) self.assertEqual(presentations, [newTarget]) self.assertFalse(testScript._lastCommandWasCaretNav) self.assertFalse(testScript._lastCommandWasStructNav) defaultFocusHandler.assert_called_once_with(testScript, realFocus) def _assert_document_focus_uses_recovered_context_once( self, *, pageNavigation: bool, documentFragment: str, ) -> None: document = FakeAccessible(Atspi.Role.DOCUMENT_WEB) oldFocus = FakeAccessible(Atspi.Role.FRAME) context = FakeAccessible(Atspi.Role.LINK) testScript = self._make_script(document, context) testScript.utilities.getDocumentForObject.side_effect = ( lambda obj: document if obj is document else None ) testScript.utilities.getCaretContext.return_value = (None, -1) testScript.utilities.searchForCaretContext.return_value = (context, 6) testScript.utilities.lastInputEventWasPageNav.return_value = pageNavigation testScript.utilities.isLink.side_effect = lambda obj: obj is context testScript.utilities.documentFragment.return_value = documentFragment event = FakeEvent("object:state-changed:focused", document, detail1=1) presentations: list[FakeAccessible] = [] def set_focus( _event: FakeEvent, obj: FakeAccessible, notifyScript: bool = True, ) -> None: if notifyScript: presentations.append(obj) with ( mock.patch.object(cthulhu_state, "locusOfFocus", oldFocus), mock.patch.object(web_script.AXUtilities, "is_editable", return_value=False), mock.patch.object(web_script.AXUtilities, "is_dialog_or_alert", return_value=False), mock.patch.object(web_script.AXUtilities, "is_focusable", return_value=True), mock.patch.object( web_script.AXUtilities, "is_focused", side_effect=lambda obj: obj is document, ), mock.patch.object( web_script.AXUtilities, "is_document", side_effect=lambda obj: obj is document, ), mock.patch.object(web_script.AXObject, "clear_cache"), mock.patch.object(web_script.cthulhu, "setLocusOfFocus", side_effect=set_focus) as setFocus, mock.patch.object(default.Script, "onFocusedChanged") as defaultFocusHandler, ): WebEventSequence(testScript).run([event]) self.assertEqual(presentations, [context]) self.assertEqual( setFocus.call_args_list, [mock.call(event, context, False), mock.call(event, context)], ) testScript.utilities.setCaretContext.assert_has_calls( [mock.call(context, 6), mock.call(context, 6)] if pageNavigation else [mock.call(context, 6)] ) defaultFocusHandler.assert_not_called() def test_page_navigation_focus_uses_recovered_caret_context_once(self) -> None: self._assert_document_focus_uses_recovered_context_once( pageNavigation=True, documentFragment="", ) def test_page_fragment_focus_uses_recovered_caret_context_once(self) -> None: self._assert_document_focus_uses_recovered_context_once( pageNavigation=False, documentFragment="#details", ) class ChromiumNativeContainerSequenceTests(unittest.TestCase): """Current routing contracts for native containers in Chromium content.""" @staticmethod def _make_script(document: object, caretContext: object) -> chromium_script.Script: testScript = chromium_script.Script.__new__(chromium_script.Script) testScript._lastCommandWasCaretNav = False testScript._lastCommandWasStructNav = False testScript._browseModeIsSticky = False testScript.utilities = mock.Mock() testScript.utilities.isDocument.return_value = False testScript.utilities.isZombie.return_value = False testScript.utilities.getDocumentForObject.return_value = document testScript.utilities.isWebAppDescendant.return_value = False testScript.utilities.handleEventFromContextReplicant.return_value = False testScript.utilities.getCaretContext.return_value = (caretContext, 0) testScript.utilities.inDocumentContent.return_value = True testScript.utilities.eventIsBrowserUIAutocompleteNoise.return_value = False testScript.utilities.eventIsBrowserUIPageSwitch.return_value = False testScript.utilities.eventIsFromLocusOfFocusDocument.return_value = True testScript.utilities.eventIsIrrelevantSelectionChangedEvent.return_value = False testScript.utilities.commonAncestor.return_value = None return testScript def _run_container_sequence( self, events: list[FakeEvent], expectedOwner: str, ) -> None: document = object() oldFocus = object() testScript = self._make_script(document, oldFocus) presentations: list[tuple[str, FakeAccessible]] = [] with ( mock.patch.object(cthulhu_state, "locusOfFocus", oldFocus), mock.patch.object(web_script.AXUtilities, "is_editable", return_value=False), mock.patch.object(web_script.AXUtilities, "is_dialog_or_alert", return_value=False), mock.patch.object(web_script.AXUtilities, "is_focusable", return_value=True), mock.patch.object(web_script.AXUtilities, "is_focused", return_value=True), mock.patch.object(web_script.AXUtilities, "is_document", return_value=False), mock.patch.object( default.Script, "onActiveDescendantChanged", side_effect=lambda _script, event: presentations.append( ("active-descendant", event.any_data) ), ) as defaultActiveDescendantHandler, mock.patch.object( default.Script, "onSelectionChanged", side_effect=lambda _script, event: presentations.append( ("selection", event.source) ), ) as defaultSelectionHandler, mock.patch.object( default.Script, "onFocusedChanged", side_effect=lambda _script, event: presentations.append(("focus", event.source)), ) as defaultFocusHandler, ): WebEventSequence(testScript).run(events) self.assertEqual(presentations, [(expectedOwner, events[-1].source)]) defaultActiveDescendantHandler.assert_not_called() if expectedOwner == "selection": defaultSelectionHandler.assert_called_once_with(testScript, events[-1]) defaultFocusHandler.assert_not_called() else: defaultSelectionHandler.assert_not_called() defaultFocusHandler.assert_called_once_with(testScript, events[-1]) def test_combo_box_active_descendant_then_focus_uses_focus_owner(self) -> None: comboBox = FakeAccessible(Atspi.Role.COMBO_BOX) option = FakeAccessible(Atspi.Role.LIST_ITEM) events = [ FakeEvent("object:active-descendant-changed", comboBox, any_data=option), FakeEvent("object:state-changed:focused", comboBox, detail1=1), ] self._run_container_sequence(events, expectedOwner="focus") def test_listbox_active_descendant_then_selection_uses_selection_owner(self) -> None: listBox = FakeAccessible(Atspi.Role.LIST_BOX) option = FakeAccessible(Atspi.Role.LIST_ITEM) events = [ FakeEvent("object:active-descendant-changed", listBox, any_data=option), FakeEvent("object:selection-changed", listBox), ] self._run_container_sequence(events, expectedOwner="selection") def test_tree_active_descendant_then_focus_uses_focus_owner(self) -> None: tree = FakeAccessible(Atspi.Role.TREE) treeItem = FakeAccessible(Atspi.Role.TREE_ITEM) events = [ FakeEvent("object:active-descendant-changed", tree, any_data=treeItem), FakeEvent("object:state-changed:focused", treeItem, detail1=1), ] self._run_container_sequence(events, expectedOwner="focus") def test_grid_active_descendant_then_selection_uses_selection_owner(self) -> None: grid = FakeAccessible(Atspi.Role.TABLE) gridCell = FakeAccessible(Atspi.Role.TABLE_CELL) events = [ FakeEvent("object:active-descendant-changed", grid, any_data=gridCell), FakeEvent("object:selection-changed", grid), ] self._run_container_sequence(events, expectedOwner="selection") def test_dialog_focus_loss_then_gain_uses_shared_focus_owner(self) -> None: document = object() oldFocus = object() dialog = FakeAccessible(Atspi.Role.DIALOG) testScript = self._make_script(document, oldFocus) events = [ FakeEvent("object:state-changed:focused", dialog, detail1=0), FakeEvent("object:state-changed:focused", dialog, detail1=1), ] presentations: list[FakeAccessible] = [] with ( mock.patch.object(cthulhu_state, "locusOfFocus", oldFocus), mock.patch.object(web_script.AXUtilities, "is_editable", return_value=False), mock.patch.object( web_script.AXUtilities, "is_dialog_or_alert", side_effect=lambda obj: obj is dialog, ), mock.patch( "cthulhu.scripts.web.script.cthulhu.setLocusOfFocus", side_effect=lambda _event, obj: presentations.append(obj), ) as setLocusOfFocus, mock.patch.object(default.Script, "onFocusedChanged") as defaultFocusHandler, ): WebEventSequence(testScript).run(events) self.assertEqual(presentations, [dialog]) setLocusOfFocus.assert_called_once_with(events[1], dialog) defaultFocusHandler.assert_not_called() class ChromiumImplementationDetailSequenceTests(unittest.TestCase): """Routing contracts for Chromium-only accessibility-tree details.""" @staticmethod def _make_script( staticTextLeaf: FakeAccessible | None = None, listItemMarker: FakeAccessible | None = None, ) -> chromium_script.Script: testScript = chromium_script.Script.__new__(chromium_script.Script) testScript.utilities = mock.Mock() testScript.utilities.isStaticTextLeaf.side_effect = ( lambda obj: obj is staticTextLeaf ) testScript.utilities.isListItemMarker.side_effect = ( lambda obj: obj is listItemMarker ) testScript.utilities.isRedundantAutocompleteEvent.return_value = False return testScript def test_static_text_child_caret_then_parent_caret_uses_parent_owner(self) -> None: staticText = FakeAccessible(Atspi.Role.STATIC) parentText = FakeAccessible(Atspi.Role.PARAGRAPH) testScript = self._make_script(staticTextLeaf=staticText) events = [ FakeEvent("object:text-caret-moved", staticText, detail1=0), FakeEvent("object:text-caret-moved", parentText, detail1=0), ] presentations: list[FakeAccessible] = [] with ( mock.patch.object(web_script.Script, "onCaretMoved", return_value=False) as webHandler, mock.patch.object( default.Script, "onCaretMoved", side_effect=lambda _script, event: presentations.append(event.source), ) as defaultHandler, ): WebEventSequence(testScript).run(events) self.assertEqual(presentations, [parentText]) webHandler.assert_called_once_with(events[1]) defaultHandler.assert_called_once_with(testScript, events[1]) def test_static_text_child_selection_then_parent_selection_uses_parent_owner(self) -> None: staticText = FakeAccessible(Atspi.Role.STATIC) parentText = FakeAccessible(Atspi.Role.PARAGRAPH) testScript = self._make_script(staticTextLeaf=staticText) events = [ FakeEvent("object:text-selection-changed", staticText), FakeEvent("object:text-selection-changed", parentText), ] self._assert_text_selection_owner(testScript, events, parentText) def test_list_marker_selection_then_list_item_selection_uses_list_item_owner(self) -> None: listMarker = FakeAccessible(Atspi.Role.STATIC) listItem = FakeAccessible(Atspi.Role.LIST_ITEM) testScript = self._make_script(listItemMarker=listMarker) events = [ FakeEvent("object:text-selection-changed", listMarker), FakeEvent("object:text-selection-changed", listItem), ] self._assert_text_selection_owner(testScript, events, listItem) def _assert_text_selection_owner( self, testScript: chromium_script.Script, events: list[FakeEvent], expectedOwner: FakeAccessible, ) -> None: presentations: list[FakeAccessible] = [] with ( mock.patch.object( web_script.Script, "onTextSelectionChanged", return_value=False, ) as webHandler, mock.patch.object( default.Script, "onTextSelectionChanged", side_effect=lambda _script, event: presentations.append(event.source), ) as defaultHandler, ): WebEventSequence(testScript).run(events) self.assertEqual(presentations, [expectedOwner]) webHandler.assert_called_once_with(events[1]) defaultHandler.assert_called_once_with(testScript, events[1]) class ChromiumMutationRecoverySequenceTests(unittest.TestCase): """Routing contracts for stale context replacement after child removal.""" @staticmethod def _make_script(document: object, recoveredContext: FakeAccessible) -> chromium_script.Script: testScript = chromium_script.Script.__new__(chromium_script.Script) testScript._browseModeIsSticky = False testScript._loadingDocumentContent = False testScript.lastMouseRoutingTime = 0 testScript.utilities = mock.Mock() testScript.utilities.isStaticTextLeaf.return_value = False testScript.utilities.eventIsBrowserUINoise.return_value = False testScript.utilities.isLiveRegion.return_value = False testScript.utilities.getTopLevelDocumentForObject.return_value = document testScript.utilities.getDocumentForObject.return_value = document testScript.utilities.inDocumentContent.return_value = True testScript.utilities.isZombie.return_value = False testScript.utilities.isWebAppDescendant.return_value = False testScript.utilities.handleEventForRemovedChild.return_value = False testScript.utilities.handleEventFromContextReplicant.side_effect = ( lambda _event, candidate: candidate is recoveredContext ) return testScript def _assert_removed_context_then_replicant_recovers_once( self, recoveryEventType: str, ) -> None: document = object() container = FakeAccessible(Atspi.Role.PANEL) removedContext = FakeAccessible(Atspi.Role.PUSH_BUTTON) recoveredContext = FakeAccessible(Atspi.Role.PUSH_BUTTON) testScript = self._make_script(document, recoveredContext) removalEvent = FakeEvent( "object:children-changed:remove", container, detail1=0, any_data=removedContext, ) if recoveryEventType == "object:children-changed:add": recoveryEvent = FakeEvent( recoveryEventType, container, detail1=0, any_data=recoveredContext, ) else: recoveryEvent = FakeEvent(recoveryEventType, recoveredContext, detail1=1) with ( mock.patch.object(cthulhu_state, "locusOfFocus", removedContext), mock.patch.object(web_script.AXObject, "clear_cache_now"), mock.patch.object(web_script.AXObject, "is_dead", return_value=True), mock.patch.object(web_script.AXUtilities, "is_busy", return_value=False), mock.patch.object(web_script.AXUtilities, "is_editable", return_value=False), mock.patch.object(web_script.AXUtilities, "is_dialog_or_alert", return_value=False), mock.patch.object(default.Script, "onChildrenRemoved") as defaultRemovalHandler, mock.patch.object(default.Script, "onChildrenAdded") as defaultAddedHandler, mock.patch.object(default.Script, "onFocusedChanged") as defaultFocusHandler, ): WebEventSequence(testScript).run([removalEvent, recoveryEvent]) defaultRemovalHandler.assert_called_once_with(testScript, removalEvent) testScript.utilities.handleEventForRemovedChild.assert_called_once_with(removalEvent) testScript.utilities.handleEventFromContextReplicant.assert_called_once_with( recoveryEvent, recoveredContext, ) defaultAddedHandler.assert_not_called() defaultFocusHandler.assert_not_called() def test_removed_context_then_added_replicant_uses_replicant_owner(self) -> None: self._assert_removed_context_then_replicant_recovers_once( "object:children-changed:add" ) def test_removed_context_then_focused_replicant_uses_replicant_owner(self) -> None: self._assert_removed_context_then_replicant_recovers_once( "object:state-changed:focused" ) class StickyModeTransitionSequenceTests(unittest.TestCase): """Ordered document-exit and re-entry contracts for sticky modes.""" @staticmethod def _make_script( document: object, documentObjects: set[FakeAccessible], inFocusMode: bool, focusModeIsSticky: bool, browseModeIsSticky: bool, ) -> chromium_script.Script: testScript = chromium_script.Script.__new__(chromium_script.Script) testScript._lastCommandWasCaretNav = False testScript._lastCommandWasStructNav = False testScript._lastCommandWasMouseButton = False testScript._madeFindAnnouncement = False testScript._inFocusMode = inFocusMode testScript._focusModeIsSticky = focusModeIsSticky testScript._browseModeIsSticky = browseModeIsSticky testScript._navSuspended = False testScript.refreshKeyGrabs = mock.Mock() testScript.presentMessage = mock.Mock() testScript.utilities = mock.Mock() testScript.utilities.isZombie.return_value = False testScript.utilities.isDocument.return_value = False testScript.utilities.getTopLevelDocumentForObject.side_effect = ( lambda obj: document if obj in documentObjects else None ) testScript.utilities.getDocumentForObject.side_effect = ( lambda obj: document if obj in documentObjects else None ) testScript.utilities.inDocumentContent.side_effect = ( lambda obj: obj in documentObjects ) testScript.utilities.inFindContainer.return_value = False testScript.utilities.queryNonEmptyText.return_value = None testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = False testScript.utilities.isAnchor.return_value = False testScript.utilities.lastInputEventWasPageNav.return_value = False testScript.utilities.isFocusedWithMathChild.return_value = False testScript.utilities.caretMovedToSamePageFragment.return_value = False testScript.utilities.lastInputEventWasLineNav.return_value = False testScript.utilities.shouldInterruptForLocusOfFocusChange.return_value = False testScript.utilities.isWebAppDescendant.return_value = False testScript.flatReviewPresenter = mock.Mock() testScript.flatReviewPresenter.is_active.return_value = False testScript.updateBraille = mock.Mock() testScript.useFocusMode = mock.Mock(return_value=False) testScript.speechGenerator = mock.Mock() testScript.speechGenerator.generateSpeech.return_value = [] testScript._saveFocusedObjectInfo = mock.Mock() def set_navigation_suspended(suspended: bool, _reason: str) -> None: testScript._navSuspended = suspended testScript._setNavigationSuspended = mock.Mock( side_effect=set_navigation_suspended ) return testScript @staticmethod def _run_round_trip( testScript: chromium_script.Script, documentControl: FakeAccessible, browserUI: FakeAccessible, returnedControl: FakeAccessible, ) -> list[tuple[bool, bool, bool, bool]]: snapshots: list[tuple[bool, bool, bool, bool]] = [] transitions = [ (documentControl, browserUI), (browserUI, returnedControl), ] for oldFocus, newFocus in transitions: web_script.Script.locus_of_focus_changed( testScript, None, oldFocus, newFocus, ) snapshots.append( ( testScript._inFocusMode, testScript._focusModeIsSticky, testScript._browseModeIsSticky, testScript._navSuspended, ) ) return snapshots def test_sticky_focus_survives_document_exit_and_reentry(self) -> None: document = object() documentControl = FakeAccessible(Atspi.Role.ENTRY, editable=True) browserUI = FakeAccessible(Atspi.Role.ENTRY, editable=True) returnedControl = FakeAccessible(Atspi.Role.ENTRY, editable=True) testScript = self._make_script( document, {documentControl, returnedControl}, inFocusMode=True, focusModeIsSticky=True, browseModeIsSticky=False, ) with ( mock.patch.object(web_script.AXObject, "is_dead", return_value=False), mock.patch.object(web_script.AXUtilities, "is_unknown_or_redundant", return_value=False), mock.patch.object(web_script.AXUtilities, "is_heading", return_value=False), mock.patch.object(web_script.speech, "speak"), mock.patch.object(web_script.cthulhu, "emitRegionChanged"), ): snapshots = self._run_round_trip( testScript, documentControl, browserUI, returnedControl, ) self.assertEqual( snapshots, [ (True, True, False, True), (True, True, False, False), ], ) testScript._setNavigationSuspended.assert_has_calls( [ mock.call(True, "focus left document content"), mock.call(False, "focus entered document content"), ] ) def test_sticky_browse_survives_round_trip_and_blocks_widget_focus(self) -> None: document = object() documentControl = FakeAccessible(Atspi.Role.PARAGRAPH) browserUI = FakeAccessible(Atspi.Role.ENTRY, editable=True) webAppWidget = FakeAccessible(Atspi.Role.PUSH_BUTTON) testScript = self._make_script( document, {documentControl, webAppWidget}, inFocusMode=False, focusModeIsSticky=False, browseModeIsSticky=True, ) testScript.utilities.isWebAppDescendant.side_effect = ( lambda obj: obj is webAppWidget ) focusEvent = FakeEvent( "object:state-changed:focused", webAppWidget, detail1=1, ) with ( mock.patch.object(cthulhu_state, "locusOfFocus", browserUI), mock.patch.object(web_script.AXObject, "is_dead", return_value=False), mock.patch.object(web_script.AXUtilities, "is_unknown_or_redundant", return_value=False), mock.patch.object(web_script.AXUtilities, "is_heading", return_value=False), mock.patch.object(web_script.speech, "speak"), mock.patch.object(web_script.cthulhu, "emitRegionChanged"), mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus, mock.patch.object(default.Script, "onFocusedChanged") as defaultFocusHandler, ): snapshots = self._run_round_trip( testScript, documentControl, browserUI, webAppWidget, ) WebEventSequence(testScript).run([focusEvent]) self.assertEqual( snapshots, [ (False, False, True, True), (False, False, True, False), ], ) self.assertFalse(testScript._inFocusMode) self.assertFalse(testScript._focusModeIsSticky) self.assertTrue(testScript._browseModeIsSticky) setLocusOfFocus.assert_not_called() defaultFocusHandler.assert_not_called() class DocumentAndBrowserUIRoundTripSequenceTests(unittest.TestCase): """Lifecycle and browser-UI round-trip contracts for web document context.""" def test_script_deactivate_then_reactivate_restores_document_navigation(self) -> None: testScript = chromium_script.Script.__new__(chromium_script.Script) app = object() document = object() focus = FakeAccessible(Atspi.Role.PARAGRAPH) testScript.app = app testScript._sayAllContents = [object()] testScript._inSayAll = True testScript._sayAllIsInterrupted = True testScript._loadingDocumentContent = True testScript._madeFindAnnouncement = True testScript._lastCommandWasCaretNav = True testScript._lastCommandWasStructNav = True testScript._lastCommandWasMouseButton = True testScript._lastMouseButtonContext = (object(), 3) testScript._lastMouseOverObject = object() testScript._preMouseOverContext = (object(), 4) testScript._inMouseOverObject = True testScript.utilities = mock.Mock() testScript.utilities.getDocumentForObject.return_value = document testScript._setNavigationSuspended = mock.Mock() testScript.removeKeyGrabs = mock.Mock() with ( mock.patch.object(cthulhu_state, "locusOfFocus", focus), mock.patch.object(web_script.AXObject, "is_dead", return_value=False), mock.patch.object(web_script.AXObject, "get_application", return_value=app), mock.patch.object(default.Script, "activate") as defaultActivate, ): web_script.Script.deactivate(testScript) web_script.Script.activate(testScript) self.assertEqual(testScript._sayAllContents, []) self.assertFalse(testScript._inSayAll) self.assertFalse(testScript._sayAllIsInterrupted) self.assertFalse(testScript._loadingDocumentContent) self.assertFalse(testScript._madeFindAnnouncement) self.assertFalse(testScript._lastCommandWasCaretNav) self.assertFalse(testScript._lastCommandWasStructNav) self.assertFalse(testScript._lastCommandWasMouseButton) self.assertEqual(testScript._lastMouseButtonContext, (None, -1)) self.assertIsNone(testScript._lastMouseOverObject) self.assertEqual(testScript._preMouseOverContext, (None, -1)) self.assertFalse(testScript._inMouseOverObject) testScript.utilities.clearCachedObjects.assert_called_once_with() testScript._setNavigationSuspended.assert_has_calls( [ mock.call(False, "script deactivation"), mock.call(False, "activation-in-document"), ] ) testScript.removeKeyGrabs.assert_called_once_with() defaultActivate.assert_called_once_with() def test_address_bar_round_trip_has_one_owner_per_focus_and_recovers_context( self, ) -> None: document = object() pageControl = FakeAccessible(Atspi.Role.ENTRY, editable=True) addressBar = FakeAccessible(Atspi.Role.ENTRY, editable=True) returnedControl = FakeAccessible(Atspi.Role.ENTRY, editable=True) testScript = StickyModeTransitionSequenceTests._make_script( document, {pageControl, returnedControl}, inFocusMode=False, focusModeIsSticky=False, browseModeIsSticky=False, ) focusEvents = [ FakeEvent("object:state-changed:focused", addressBar, detail1=1), FakeEvent("object:state-changed:focused", returnedControl, detail1=1), ] presentations: list[FakeAccessible] = [] def present_and_move_focus( _script: chromium_script.Script, event: FakeEvent, ) -> None: oldFocus = cthulhu_state.locusOfFocus presentations.append(event.source) web_script.Script.locus_of_focus_changed( testScript, event, oldFocus, event.source, ) cthulhu_state.locusOfFocus = event.source with ( mock.patch.object(cthulhu_state, "locusOfFocus", pageControl), mock.patch.object(web_script.AXObject, "is_dead", return_value=False), mock.patch.object(web_script.AXUtilities, "is_editable", side_effect=lambda obj: obj.editable), mock.patch.object(web_script.AXUtilities, "is_dialog_or_alert", return_value=False), mock.patch.object(web_script.AXUtilities, "is_unknown_or_redundant", return_value=False), mock.patch.object(web_script.AXUtilities, "is_heading", return_value=False), mock.patch.object(web_script.speech, "speak"), mock.patch.object(web_script.cthulhu, "emitRegionChanged"), mock.patch.object( default.Script, "onFocusedChanged", side_effect=present_and_move_focus, ) as defaultFocusHandler, ): WebEventSequence(testScript).run(focusEvents) self.assertEqual(presentations, [addressBar, returnedControl]) self.assertEqual( defaultFocusHandler.call_args_list, [ mock.call(testScript, focusEvents[0]), mock.call(testScript, focusEvents[1]), ], ) testScript._setNavigationSuspended.assert_has_calls( [ mock.call(True, "focus left document content"), mock.call(False, "focus entered document content"), ] ) self.assertFalse(testScript._navSuspended) testScript.utilities.setCaretContext.assert_called_once_with( returnedControl, 0, document, ) testScript.updateBraille.assert_called_once_with( returnedControl, documentFrame=document, ) class ChromiumDocumentLifecycleSequenceTests(unittest.TestCase): """Routing contracts for transient and authoritative Chromium documents.""" @staticmethod def _make_script( transientDocument: FakeAccessible, realDocument: FakeAccessible, ) -> chromium_script.Script: testScript = chromium_script.Script.__new__(chromium_script.Script) testScript.utilities = mock.Mock() testScript.utilities.hasNoSize.return_value = False testScript.utilities.documentFrameURI.side_effect = ( lambda obj: "" if obj is transientDocument else "https://example.test/" ) return testScript def _assert_real_document_owns_sequence( self, eventType: str, webHandlerName: str, defaultHandlerName: str, detail1: int = 0, ) -> None: transientDocument = FakeAccessible(Atspi.Role.DOCUMENT_WEB) realDocument = FakeAccessible(Atspi.Role.DOCUMENT_WEB) testScript = self._make_script(transientDocument, realDocument) events = [ FakeEvent(eventType, transientDocument, detail1=detail1), FakeEvent(eventType, realDocument, detail1=detail1), ] handledDocuments: list[FakeAccessible] = [] def handle_real_document(event: FakeEvent) -> bool: handledDocuments.append(event.source) return True with ( mock.patch.object( web_script.Script, webHandlerName, side_effect=handle_real_document, ) as webHandler, mock.patch.object(default.Script, defaultHandlerName) as defaultHandler, ): WebEventSequence(testScript).run(events) self.assertEqual(handledDocuments, [realDocument]) webHandler.assert_called_once_with(events[1]) defaultHandler.assert_not_called() def test_uri_less_busy_then_real_busy_uses_real_document_owner(self) -> None: self._assert_real_document_owns_sequence( "object:state-changed:busy", "onBusyChanged", "onBusyChanged", detail1=1, ) def test_uri_less_load_complete_then_real_load_complete_uses_real_document_owner( self, ) -> None: self._assert_real_document_owns_sequence( "document:load-complete", "onDocumentLoadComplete", "onDocumentLoadComplete", ) def test_uri_less_reload_then_real_reload_uses_real_document_owner(self) -> None: self._assert_real_document_owns_sequence( "document:reload", "onDocumentReload", "onDocumentReload", ) class GeckoFocusAndFindSequenceTests(unittest.TestCase): """Routing contracts for Gecko focus noise and find-result events.""" @staticmethod def _make_script() -> gecko_script.Script: testScript = gecko_script.Script.__new__(gecko_script.Script) testScript._lastCommandWasCaretNav = False testScript._lastCommandWasStructNav = False testScript._lastCommandWasMouseButton = False testScript._clearSyntheticWebSelection = mock.Mock() testScript._saveFocusedObjectInfo = mock.Mock() testScript.presentFindResults = mock.Mock() testScript.utilities = mock.Mock() testScript.utilities.isZombie.return_value = False testScript.utilities.getTopLevelDocumentForObject.return_value = object() testScript.utilities.getCaretContext.return_value = (object(), 0) testScript.utilities.lastInputEventWasCaretNavWithSelection.return_value = False testScript.utilities.lastInputEventWasCharNav.return_value = False testScript.utilities.inFindContainer.return_value = True return testScript def _assert_focus_noise_then_control_uses_control_owner( self, noiseRole: Atspi.Role, ) -> None: activeWindow = FakeAccessible(Atspi.Role.FRAME) noise = FakeAccessible(noiseRole) control = FakeAccessible(Atspi.Role.ENTRY, editable=True) testScript = self._make_script() events = [ FakeEvent("object:state-changed:focused", noise, detail1=1), FakeEvent("object:state-changed:focused", control, detail1=1), ] presentations: list[FakeAccessible] = [] with ( mock.patch.object(cthulhu_state, "activeWindow", activeWindow), mock.patch.object(cthulhu_state, "locusOfFocus", activeWindow), mock.patch.object(web_script.Script, "onFocusedChanged", return_value=False) as webHandler, mock.patch.object( gecko_script.AXObject, "get_role", side_effect=lambda obj: obj.role, ), mock.patch.object( default.Script, "onFocusedChanged", side_effect=lambda _script, event: presentations.append(event.source), ) as defaultHandler, ): GeckoEventSequence(testScript).run(events) self.assertEqual(presentations, [control]) self.assertEqual(webHandler.call_args_list, [mock.call(events[0]), mock.call(events[1])]) defaultHandler.assert_called_once_with(testScript, events[1]) def test_panel_focus_noise_then_real_focus_uses_real_control_owner(self) -> None: self._assert_focus_noise_then_control_uses_control_owner(Atspi.Role.PANEL) def test_frame_focus_noise_then_real_focus_uses_real_control_owner(self) -> None: self._assert_focus_noise_then_control_uses_control_owner(Atspi.Role.FRAME) def test_find_entry_focus_then_result_caret_uses_find_presenter(self) -> None: findEntry = FakeAccessible(Atspi.Role.ENTRY, editable=True) resultText = FakeAccessible(Atspi.Role.PARAGRAPH) testScript = self._make_script() events = [ FakeEvent("object:state-changed:focused", findEntry, detail1=1), FakeEvent("object:text-caret-moved", resultText, detail1=7), ] focusPresentations: list[FakeAccessible] = [] with ( mock.patch.object(cthulhu_state, "activeWindow", FakeAccessible(Atspi.Role.FRAME)), mock.patch.object(cthulhu_state, "locusOfFocus", findEntry), mock.patch.object(web_script.Script, "onFocusedChanged", return_value=False), mock.patch.object( gecko_script.AXObject, "get_role", side_effect=lambda obj: obj.role, ), mock.patch.object( default.Script, "onFocusedChanged", side_effect=lambda _script, event: focusPresentations.append(event.source), ) as defaultFocusHandler, mock.patch.object(default.Script, "onCaretMoved") as defaultCaretHandler, mock.patch.object(AXUtilitiesEvent, "get_text_event_reason", return_value=None), ): GeckoEventSequence(testScript).run(events) self.assertEqual(focusPresentations, [findEntry]) defaultFocusHandler.assert_called_once_with(testScript, events[0]) testScript.presentFindResults.assert_called_once_with(resultText, 7) testScript._saveFocusedObjectInfo.assert_called_once_with(findEntry) defaultCaretHandler.assert_not_called() if __name__ == "__main__": unittest.main()