Files
cthulhu/tests/test_web_event_sequences.py
Storm Dragon e3427af1ad Updated tests.
2026-07-24 19:45:47 -04:00

1217 lines
52 KiB
Python

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