From 0858ec441a600fe290eeb4f0dc53332d1d21c9b4 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 10 Jul 2026 11:43:35 -0400 Subject: [PATCH] Initial work on making Steam overlay accessible. --- src/cthulhu/event_manager.py | 125 +++- .../scripts/apps/steamwebhelper/script.py | 500 ++++++++++++- ..._manager_compositor_context_regressions.py | 36 + tests/test_steam_selection_regressions.py | 678 ++++++++++++++++++ 4 files changed, 1335 insertions(+), 4 deletions(-) diff --git a/src/cthulhu/event_manager.py b/src/cthulhu/event_manager.py index d131ce2..930eaf4 100644 --- a/src/cthulhu/event_manager.py +++ b/src/cthulhu/event_manager.py @@ -328,6 +328,118 @@ class EventManager: def _isSteamNotificationEvent(self, event: Atspi.Event) -> bool: return self._isLiveOrNotificationEvent(event) + def _isSteamOverlayNavigationEvent(self, event: Atspi.Event, app: Atspi.Accessible) -> bool: + if not self._isSteamApp(app): + return False + + if not event.type.startswith(( + "object:property-change:accessible-name", + "object:active-descendant-changed", + )): + return False + + if not self._activeX11WindowIsSteamSelectionContext(): + return False + + return self._eventHasSteamQuickAccessContext(event) + + def _eventHasSteamQuickAccessContext(self, event: Atspi.Event) -> bool: + for obj in (event.source, getattr(event, "any_data", None)): + if obj is None or isinstance(obj, (str, bytes, int, float, bool)): + continue + try: + if self._hasSteamQuickAccessMarker(obj): + return True + except Exception: + continue + + return False + + @staticmethod + def _hasSteamQuickAccessMarker(obj: Any) -> bool: + def hasMarker(candidate: Any) -> bool: + if candidate is None: + return False + + name = AXObject.get_name(candidate) or "" + if name.startswith("QuickAccess_uid"): + return True + + accessibleId = AXObject.get_attribute(candidate, "id") or "" + if accessibleId.startswith("quickaccess_"): + return True + + className = AXObject.get_attribute(candidate, "class") or "" + return "quickaccess" in className.casefold() + + return AXObject.find_ancestor_inclusive(obj, hasMarker) is not None + + def _activeX11WindowIsSteamSelectionContext(self) -> bool: + window = input_event_manager.get_manager()._get_active_x11_window() + return self._x11WindowHasSteamGameClass(window) or self._x11WindowIsSteamBigPicture(window) + + @classmethod + def _x11WindowHasSteamGameClass(cls, window: Any) -> bool: + if window is None: + return False + + identifiers = [ + cls._safeSteamWindowCall(window, "get_class_group_name"), + cls._safeSteamWindowCall(window, "get_class_instance_name"), + ] + classGroup = cls._safeSteamWindowCall(window, "get_class_group") + if classGroup is not None: + identifiers.extend([ + cls._safeSteamWindowCall(classGroup, "get_name"), + cls._safeSteamWindowCall(classGroup, "get_res_class"), + ]) + + return any(cls._isSteamGameWindowIdentifier(identifier) for identifier in identifiers) + + @classmethod + def _x11WindowIsSteamBigPicture(cls, window: Any) -> bool: + if window is None: + return False + + name = cls._safeSteamWindowCall(window, "get_name") + if not isinstance(name, str) or name.strip() != "Steam Big Picture Mode": + return False + + identifiers = [ + cls._safeSteamWindowCall(window, "get_class_group_name"), + cls._safeSteamWindowCall(window, "get_class_instance_name"), + ] + classGroup = cls._safeSteamWindowCall(window, "get_class_group") + if classGroup is not None: + identifiers.extend([ + cls._safeSteamWindowCall(classGroup, "get_name"), + cls._safeSteamWindowCall(classGroup, "get_res_class"), + ]) + + normalized = { + identifier.strip().casefold() + for identifier in identifiers + if isinstance(identifier, str) + } + return bool(normalized.intersection({"steam", "steamwebhelper"})) + + @staticmethod + def _safeSteamWindowCall(window: Any, attrName: str): + attr = getattr(window, attrName, None) + if not callable(attr): + return None + + try: + return attr() + except Exception: + return None + + @staticmethod + def _isSteamGameWindowIdentifier(identifier: Any) -> bool: + if not isinstance(identifier, str): + return False + return identifier.strip().casefold().startswith("steam_app_") + def _isLiveOrNotificationObject(self, obj: Any) -> bool: if obj is None or isinstance(obj, (str, bytes, int, float, bool)): return False @@ -559,18 +671,27 @@ class EventManager: if self._inDeluge() and self._ignoreDuringDeluge(event): return _ignore_with_reason("deluge", "event type during deluge") + isSteamOverlayNavigationEvent = self._isSteamOverlayNavigationEvent(event, app) script = cthulhu_state.activeScript if event.type.startswith('object:children-changed') \ or event.type.startswith('object:state-changed:sensitive'): if not script: - return _ignore_with_reason("no-active-script", "no active script") - if script.app != app: + if isSteamOverlayNavigationEvent: + _log_allow("steam-overlay-navigation", "no active script") + else: + return _ignore_with_reason("no-active-script", "no active script") + elif script.app != app: # Allow Steam notifications from inactive apps. if self._isSteamApp(app) and self._isSteamNotificationEvent(event): _log_allow("steam-notification", "inactive app notification") + elif isSteamOverlayNavigationEvent: + _log_allow("steam-overlay-navigation", "inactive app overlay navigation") else: return _ignore_with_reason("inactive-app", "event not from active app") + if isSteamOverlayNavigationEvent: + return _allow_with_reason("steam-overlay-navigation", "Steam overlay navigation event") + relevance = self._classifyRelevance(event, app) if relevance == self.RELEVANCE_DROP: return _ignore_with_reason("event-relevance-drop", "event is background churn") diff --git a/src/cthulhu/scripts/apps/steamwebhelper/script.py b/src/cthulhu/scripts/apps/steamwebhelper/script.py index f77532a..3f3626b 100644 --- a/src/cthulhu/scripts/apps/steamwebhelper/script.py +++ b/src/cthulhu/scripts/apps/steamwebhelper/script.py @@ -29,15 +29,19 @@ __license__ = "LGPL" import time import re +from typing import Any from gi.repository import GLib from cthulhu import debug +from cthulhu import input_event_manager from cthulhu import settings from cthulhu import cthulhu_state from cthulhu import settings_manager from cthulhu import structural_navigation from cthulhu.ax_object import AXObject +from cthulhu.ax_selection import AXSelection +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities from cthulhu.ax_utilities_relation import AXUtilitiesRelation from cthulhu.ax_utilities_role import AXUtilitiesRole @@ -84,6 +88,10 @@ class Script(Chromium.Script): self._lastSteamNotification = ("", 0.0) self._steamPendingNotification = None self._steamNotificationDelayMs = 500 + self._lastSteamOverlaySelection = ("", 0.0) + self._steamActiveQuickAccessTab = "" + self._steamFriendsPanelActivationTarget = (None, "", 0.0) + self._steamFriendsPanelActivationTargetMaxAge = 10.0 self._steamRelativeTimePattern = re.compile( r"^(?:just now|now|\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago)$", re.IGNORECASE @@ -118,6 +126,9 @@ class Script(Chromium.Script): self._presentSteamNotification(event.any_data) return + if self._presentSteamFriendsPanelNavigation(event): + return True + if self._handleSteamVirtualizedListMutation(event): return True @@ -156,12 +167,24 @@ class Script(Chromium.Script): return super().onFocusedChanged(event) + def onNameChanged(self, event): + """Callback for object:property-change:accessible-name events.""" + + self._logSteamNavigationEvent("name-changed", event) + if self._presentSteamFriendsPanelNavigation(event): + return True + + return super().onNameChanged(event) + def onTextInserted(self, event): """Callback for object:text-changed:insert accessibility events.""" if self._presentSteamLiveRegionText(event): return + if self._presentSteamFriendsPanelNavigation(event): + return True + super().onTextInserted(event) def onCaretMoved(self, event): @@ -178,8 +201,18 @@ class Script(Chromium.Script): """Callback for object:selection-changed accessibility events.""" self._logSteamNavigationEvent("selection-changed", event) + if self._presentSteamOverlaySelection(event): + return True return super().onSelectionChanged(event) + def onSelectedChanged(self, event): + """Callback for object:state-changed:selected accessibility events.""" + + self._logSteamNavigationEvent("selected-changed", event) + if self._presentSteamOverlaySelection(event): + return True + return super().onSelectedChanged(event) + def onActiveDescendantChanged(self, event): """Callback for object:active-descendant-changed accessibility events.""" @@ -203,6 +236,445 @@ class Script(Chromium.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return True + def _presentSteamOverlaySelection(self, event) -> bool: + if not event: + return False + + eventType = getattr(event, "type", "") + if not isinstance(eventType, str) or not ( + eventType.startswith("object:selection-changed") + or eventType.startswith("object:state-changed:selected") + ): + return False + + if not self._activeX11WindowIsSteamSelectionContext(): + return False + + if not self._steamOverlaySelectionWasUserInitiated(): + return False + + obj = self._getSteamOverlaySelectionObject(event) + if not obj or not self._isSteamOverlaySelectionObject(obj): + return False + + text = self._getSteamOverlaySelectionText(obj) + if not text: + return False + + if AXUtilities.is_page_tab(obj): + self._steamActiveQuickAccessTab = text + self._clearSteamFriendsPanelActivationTarget() + + if self._isDuplicateSteamOverlaySelection(text): + msg = f"STEAM: Suppressing duplicate overlay selection: {text}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + self.presentMessage(text, force=True, interrupt=True) + msg = f"STEAM: Presented overlay selection: {text}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + def _getSteamOverlaySelectionObject(self, event): + if event.type.startswith("object:state-changed:selected"): + if not event.detail1: + return None + return event.source + + if not event.type.startswith("object:selection-changed"): + return None + + for child in AXSelection.get_selected_children(event.source): + if child: + return child + + anyData = event.any_data + if anyData is not None and not isinstance(anyData, (bool, int, float, str)): + return anyData + + return event.source + + def _isSteamOverlaySelectionObject(self, obj) -> bool: + if not obj: + return False + + try: + if not self.utilities.inDocumentContent(obj): + return False + except Exception: + return False + + if not self._hasSteamOverlaySelectionContext(obj): + return False + + if not ( + AXUtilities.is_page_tab(obj) + or AXUtilities.is_list_item(obj) + or AXUtilities.is_menu_item(obj) + or AXUtilities.is_button(obj) + or AXUtilities.is_push_button(obj) + ): + return False + + return bool(self._getSteamOverlaySelectionText(obj)) + + def _getSteamOverlaySelectionText(self, obj) -> str: + text = self.utilities.displayedText(obj) or AXObject.get_name(obj) or "" + text = self._normalizeSteamNotificationText(text) + if text: + return text + + return self._getSteamQuickAccessTabLabel(obj) + + def _getSteamQuickAccessTabLabel(self, obj) -> str: + if not AXUtilities.is_page_tab(obj): + return "" + + if not self._hasSteamQuickAccessMarker(obj): + return "" + + tabId = AXObject.get_attribute(obj, "id") or "" + quickAccessTabLabels = { + "quickaccess_tab_3": "Friends", + } + label = quickAccessTabLabels.get(tabId, "") + if label: + msg = f"STEAM: Inferred QuickAccess tab label: {label}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + return label + + def _presentSteamFriendsPanelNavigation(self, event) -> bool: + if not self._steamFriendsPanelNavigationIsExpected(event): + return False + + obj = self._getSteamFriendsPanelNavigationObject(event) + if not obj or not self._isSteamFriendsPanelNavigationObject(obj): + return False + + text = self._getSteamFriendsPanelNavigationText(obj) + if not text: + return False + + self._rememberSteamFriendsPanelActivationTarget(obj, text) + if self._isDuplicateSteamOverlaySelection(text): + msg = f"STEAM: Suppressing duplicate Friends panel item: {text}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + self.presentMessage(text, force=True, interrupt=True) + msg = f"STEAM: Presented Friends panel item: {text}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + def _steamFriendsPanelNavigationIsExpected(self, event) -> bool: + if not event: + return False + + eventType = getattr(event, "type", "") + if not isinstance(eventType, str) or not eventType.startswith(( + "object:property-change:accessible-name", + "object:active-descendant-changed", + )): + return False + + if getattr(self, "_steamActiveQuickAccessTab", "") != "Friends": + return False + + if not self._activeX11WindowIsSteamSelectionContext(): + return False + + return self._steamOverlaySelectionWasUserInitiated() + + @staticmethod + def _getSteamFriendsPanelNavigationObject(event): + anyData = getattr(event, "any_data", None) + if anyData is not None and not isinstance(anyData, (bool, int, float, str, bytes)): + return anyData + + return getattr(event, "source", None) + + def _isSteamFriendsPanelNavigationObject(self, obj) -> bool: + try: + if not self._hasSteamQuickAccessMarker(obj): + return False + except Exception: + return False + + if AXUtilities.is_page_tab(obj) or AXUtilities.is_page_tab_list(obj): + return False + + if self._isIgnoredSteamFriendsPanelObject(obj): + return False + + return ( + AXUtilities.is_list_item(obj) + or AXUtilities.is_button(obj) + or AXUtilities.is_push_button(obj) + or AXUtilities.is_table_row(obj) + ) + + def _isIgnoredSteamFriendsPanelObject(self, obj) -> bool: + def isIgnored(candidate) -> bool: + if candidate is None: + return False + + className = AXObject.get_attribute(candidate, "class") or "" + accessibleId = AXObject.get_attribute(candidate, "id") or "" + searchable = f"{className} {accessibleId}".casefold() + ignoredMarkers = ( + "addfriendbutton", + "chathistorycontainer", + "chatdialogs", + "chatwindow", + "chatentry", + "chatmessage", + "messagelist", + "messageinput", + "textarea", + ) + return any(marker in searchable for marker in ignoredMarkers) + + try: + return AXObject.find_ancestor_inclusive(obj, isIgnored) is not None + except Exception: + return False + + def _getSteamFriendsPanelNavigationText(self, obj) -> str: + text = self._getSteamFriendsPanelReadableText(obj) + if self._isUsefulSteamFriendsPanelText(text): + return text + + return "" + + def _rememberSteamFriendsPanelActivationTarget(self, obj: Any, text: str) -> None: + self._steamFriendsPanelActivationTarget = (obj, text, time.monotonic()) + + def _clearSteamFriendsPanelActivationTarget(self) -> None: + self._steamFriendsPanelActivationTarget = (None, "", 0.0) + + def _getSteamFriendsPanelActivationTarget(self) -> Any: + if getattr(self, "_steamActiveQuickAccessTab", "") != "Friends": + return None + + if not self._activeX11WindowIsSteamSelectionContext(): + return None + + obj, text, timestamp = getattr( + self, + "_steamFriendsPanelActivationTarget", + (None, "", 0.0), + ) + if not obj or not text: + return None + + maxAge = getattr(self, "_steamFriendsPanelActivationTargetMaxAge", 10.0) + if time.monotonic() - timestamp > maxAge: + msg = f"STEAM: Clearing stale Friends panel activation target: {text}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + self._clearSteamFriendsPanelActivationTarget() + return None + + try: + if AXObject.is_dead(obj): + self._clearSteamFriendsPanelActivationTarget() + return None + except Exception: + self._clearSteamFriendsPanelActivationTarget() + return None + + if not self._isSteamFriendsPanelNavigationObject(obj): + self._clearSteamFriendsPanelActivationTarget() + return None + + return obj + + def _getSteamFriendsPanelReadableText(self, obj) -> str: + text = "" + try: + text = self.utilities.displayedText(obj) or AXObject.get_name(obj) or "" + except Exception: + try: + text = AXObject.get_name(obj) or "" + except Exception: + text = "" + + text = self._normalizeSteamFriendsPanelText(text) + if text: + return text + + try: + if AXObject.supports_text(obj): + text = AXText.get_all_text(obj) or "" + except Exception: + text = "" + + return self._normalizeSteamFriendsPanelText(text) + + def _normalizeSteamFriendsPanelText(self, text: str) -> str: + if not text: + return "" + + text = text.replace(self.EMBEDDED_OBJECT_CHARACTER, " ") + text = text.replace("[OBJ]", " ") + return " ".join(text.split()) + + @staticmethod + def _isUsefulSteamFriendsPanelText(text: str) -> bool: + if not text: + return False + + textLower = text.casefold() + if textLower in {"friends", "add friend", "unlabeled image"}: + return False + + ignoredFragments = ( + "loading older messages", + "hold to send a quick message", + "is typing a message", + ) + if any(fragment in textLower for fragment in ignoredFragments): + return False + + return not text.isdigit() + + def _hasSteamQuickAccessMarker(self, obj) -> bool: + def hasMarker(candidate) -> bool: + if candidate is None: + return False + + name = AXObject.get_name(candidate) or "" + if name.startswith("QuickAccess_uid"): + return True + + accessibleId = AXObject.get_attribute(candidate, "id") or "" + if accessibleId.startswith("quickaccess_"): + return True + + className = AXObject.get_attribute(candidate, "class") or "" + return "quickaccess" in className.casefold() + + return AXObject.find_ancestor_inclusive(obj, hasMarker) is not None + + def _hasSteamOverlaySelectionContext(self, obj) -> bool: + if self._hasSteamQuickAccessMarker(obj): + return True + + return self._isSteamBigPictureDocumentObject(obj) + + def _isSteamBigPictureDocumentObject(self, obj) -> bool: + try: + document = self.utilities.getTopLevelDocumentForObject(obj) + if not document: + document = self.utilities.getDocumentForObject(obj) + except Exception: + return False + + if not document: + return False + + try: + uri = self.utilities.documentFrameURI(document) or "" + except Exception: + uri = "" + + if "steamloopback.host" in uri.casefold(): + return True + + name = AXObject.get_name(document) or "" + return name.strip() == "Steam Big Picture Mode" + + def _steamOverlaySelectionWasUserInitiated(self) -> bool: + event = cthulhu_state.lastNonModifierKeyEvent or cthulhu_state.lastInputEvent + if event is None: + return False + + modifiers = getattr(event, "modifiers", 0) or 0 + if modifiers: + return False + + key = getattr(event, "event_string", None) or getattr(event, "keyval_name", "") + return key in {"Left", "Right", "Up", "Down", "Return", "KP_Enter", "space", " "} + + def _activeX11WindowIsSteamGame(self) -> bool: + window = input_event_manager.get_manager()._get_active_x11_window() + return self._x11WindowHasSteamGameClass(window) + + def _activeX11WindowIsSteamSelectionContext(self) -> bool: + window = input_event_manager.get_manager()._get_active_x11_window() + return self._x11WindowHasSteamGameClass(window) or self._x11WindowIsSteamBigPicture(window) + + @classmethod + def _x11WindowHasSteamGameClass(cls, window) -> bool: + if window is None: + return False + + identifiers = [ + cls._safeSteamWindowCall(window, "get_class_group_name"), + cls._safeSteamWindowCall(window, "get_class_instance_name"), + ] + classGroup = cls._safeSteamWindowCall(window, "get_class_group") + if classGroup is not None: + identifiers.extend([ + cls._safeSteamWindowCall(classGroup, "get_name"), + cls._safeSteamWindowCall(classGroup, "get_res_class"), + ]) + + return any(cls._isSteamGameWindowIdentifier(identifier) for identifier in identifiers) + + @classmethod + def _x11WindowIsSteamBigPicture(cls, window) -> bool: + if window is None: + return False + + name = cls._safeSteamWindowCall(window, "get_name") + if not isinstance(name, str) or name.strip() != "Steam Big Picture Mode": + return False + + identifiers = [ + cls._safeSteamWindowCall(window, "get_class_group_name"), + cls._safeSteamWindowCall(window, "get_class_instance_name"), + ] + classGroup = cls._safeSteamWindowCall(window, "get_class_group") + if classGroup is not None: + identifiers.extend([ + cls._safeSteamWindowCall(classGroup, "get_name"), + cls._safeSteamWindowCall(classGroup, "get_res_class"), + ]) + + normalized = { + identifier.strip().casefold() + for identifier in identifiers + if isinstance(identifier, str) + } + return bool(normalized.intersection({"steam", "steamwebhelper"})) + + @staticmethod + def _safeSteamWindowCall(window: Any, attrName: str): + attr = getattr(window, attrName, None) + if not callable(attr): + return None + + try: + return attr() + except Exception: + return None + + @staticmethod + def _isSteamGameWindowIdentifier(identifier) -> bool: + if not isinstance(identifier, str): + return False + return identifier.strip().casefold().startswith("steam_app_") + + def _isDuplicateSteamOverlaySelection(self, text: str) -> bool: + lastText, lastTime = getattr(self, "_lastSteamOverlaySelection", ("", 0.0)) + now = time.monotonic() + if text == lastText and now - lastTime < 0.75: + return True + + self._lastSteamOverlaySelection = (text, now) + return False + def _isSteamLoadingSpinnerCaretEvent(self, event) -> bool: if not (event and event.type.startswith("object:text-caret-moved")): return False @@ -241,8 +713,15 @@ class Script(Chromium.Script): if getattr(keyboardEvent, "modifiers", 0): return False - obj = self._getClickableActivationTarget() - if not obj or not self.utilities.inDocumentContent(obj): + friendsPanelTarget = self._getSteamFriendsPanelActivationTarget() + obj = friendsPanelTarget or self._getClickableActivationTarget() + if not obj: + return False + + try: + if not self.utilities.inDocumentContent(obj): + return False + except Exception: return False if not (AXUtilities.is_button(obj) or AXUtilities.is_push_button(obj)): @@ -255,16 +734,33 @@ class Script(Chromium.Script): return False if self._performClickableAction(obj): + self._logSteamFriendsPanelActivation(friendsPanelTarget) return True from cthulhu import ax_event_synthesizer result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj) if result: + self._logSteamFriendsPanelActivation(friendsPanelTarget) self._restoreFocusAfterClick(obj) return True return False + def _logSteamFriendsPanelActivation(self, obj: Any) -> None: + if obj is None: + return + + target, text, _timestamp = getattr( + self, + "_steamFriendsPanelActivationTarget", + (None, "", 0.0), + ) + if obj is not target or not text: + return + + msg = f"STEAM: Activated Friends panel item: {text}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + def _isSteamNotification(self, obj): """Detect if object is a Steam notification. diff --git a/tests/test_event_manager_compositor_context_regressions.py b/tests/test_event_manager_compositor_context_regressions.py index e4f32c6..da7b0dc 100644 --- a/tests/test_event_manager_compositor_context_regressions.py +++ b/tests/test_event_manager_compositor_context_regressions.py @@ -182,6 +182,42 @@ class EventManagerCompositorContextRegressionTests(unittest.TestCase): self.assertFalse(self.manager._ignore(firstEvent)) self.assertTrue(self.manager._ignore(secondEvent)) + def test_steam_quick_access_name_changed_survives_without_active_script(self) -> None: + app = object() + event = FakeEvent("object:property-change:accessible-name", source="quick-access") + cthulhu_state.activeScript = None + + with ( + mock.patch.object(event_manager.debug, "printMessage"), + mock.patch.object(event_manager.debug, "printTokens"), + mock.patch.object(event_manager.debug, "print_log"), + mock.patch.object(event_manager.AXObject, "get_application", return_value=app), + mock.patch.object(event_manager.AXObject, "get_name", return_value="steamwebhelper"), + ): + self.manager._isSteamOverlayNavigationEvent = mock.Mock(return_value=True) + + self.assertFalse(self.manager._ignore(event)) + + def test_steam_overlay_navigation_requires_quick_access_context(self) -> None: + app = object() + event = FakeEvent("object:property-change:accessible-name", source="not-quick-access") + + self.manager._isSteamApp = mock.Mock(return_value=True) + self.manager._activeX11WindowIsSteamSelectionContext = mock.Mock(return_value=True) + self.manager._eventHasSteamQuickAccessContext = mock.Mock(return_value=False) + + self.assertFalse(self.manager._isSteamOverlayNavigationEvent(event, app)) + + def test_steam_overlay_navigation_rejects_children_changed_churn(self) -> None: + app = object() + event = FakeEvent("object:children-changed:add", source="quick-access", any_data=object()) + + self.manager._isSteamApp = mock.Mock(return_value=True) + self.manager._activeX11WindowIsSteamSelectionContext = mock.Mock(return_value=True) + self.manager._eventHasSteamQuickAccessContext = mock.Mock(return_value=True) + + self.assertFalse(self.manager._isSteamOverlayNavigationEvent(event, app)) + def test_steam_focus_lost_burst_is_ignored_but_focus_gain_is_preserved(self) -> None: app = object() cthulhu_state.activeScript = mock.Mock(app=app) diff --git a/tests/test_steam_selection_regressions.py b/tests/test_steam_selection_regressions.py index 49d24e0..ab30ae3 100644 --- a/tests/test_steam_selection_regressions.py +++ b/tests/test_steam_selection_regressions.py @@ -61,6 +61,409 @@ class SteamSelectionChangedTests(unittest.TestCase): self.assertEqual(chromiumCalls, [(testScript, event)]) +class SteamOverlaySelectionPresentationTests(unittest.TestCase): + def test_selected_changed_presents_quick_access_item_over_steam_game(self): + testScript = steam_script.Script.__new__(steam_script.Script) + source = object() + lastEvent = mock.Mock(event_string="Down", modifiers=0) + event = mock.Mock( + type="object:state-changed:selected", + source=source, + detail1=1, + any_data=None, + ) + + testScript.utilities = mock.Mock() + testScript.utilities.inDocumentContent.return_value = True + testScript.utilities.displayedText.return_value = "" + testScript.presentMessage = mock.Mock() + testScript._lastSteamOverlaySelection = ("", 0.0) + + chromiumCalls = [] + + def chromiumOnSelectedChanged(self, selectedEvent): + chromiumCalls.append((self, selectedEvent)) + return False + + with ( + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=True, + ), + mock.patch.object(steam_script.Script, "_hasSteamQuickAccessMarker", return_value=True), + mock.patch.object(steam_script.cthulhu_state, "lastNonModifierKeyEvent", lastEvent), + mock.patch.object(steam_script.cthulhu_state, "lastInputEvent", None), + mock.patch.object(steam_script.AXObject, "get_name", return_value="Quick Settings"), + mock.patch.object(steam_script.AXUtilities, "is_page_tab", return_value=True), + mock.patch.object(steam_script.AXUtilities, "is_list_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_menu_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_button", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_push_button", return_value=False), + mock.patch.object( + steam_script.Chromium.Script, + "onSelectedChanged", + new=chromiumOnSelectedChanged, + ), + ): + self.assertTrue(testScript.onSelectedChanged(event)) + + testScript.presentMessage.assert_called_once_with( + "Quick Settings", + force=True, + interrupt=True, + ) + self.assertEqual(chromiumCalls, []) + + def test_selected_changed_presents_inferred_quick_access_friends_tab_label(self): + testScript = steam_script.Script.__new__(steam_script.Script) + source = object() + lastEvent = mock.Mock(event_string="Down", modifiers=0) + event = mock.Mock( + type="object:state-changed:selected", + source=source, + detail1=1, + any_data=None, + ) + + testScript.utilities = mock.Mock() + testScript.utilities.inDocumentContent.return_value = True + testScript.utilities.displayedText.return_value = "" + testScript.presentMessage = mock.Mock() + testScript._lastSteamOverlaySelection = ("", 0.0) + + chromiumCalls = [] + + def chromiumOnSelectedChanged(self, selectedEvent): + chromiumCalls.append((self, selectedEvent)) + return False + + with ( + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=True, + ), + mock.patch.object(steam_script.Script, "_hasSteamQuickAccessMarker", return_value=True), + mock.patch.object(steam_script.cthulhu_state, "lastNonModifierKeyEvent", lastEvent), + mock.patch.object(steam_script.cthulhu_state, "lastInputEvent", None), + mock.patch.object(steam_script.AXObject, "get_name", return_value=""), + mock.patch.object( + steam_script.AXObject, + "get_attribute", + side_effect=lambda obj, name: "quickaccess_tab_3" if name == "id" else "", + ), + mock.patch.object(steam_script.AXUtilities, "is_page_tab", return_value=True), + mock.patch.object(steam_script.AXUtilities, "is_list_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_menu_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_button", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_push_button", return_value=False), + mock.patch.object( + steam_script.Chromium.Script, + "onSelectedChanged", + new=chromiumOnSelectedChanged, + ), + ): + self.assertTrue(testScript.onSelectedChanged(event)) + + testScript.presentMessage.assert_called_once_with( + "Friends", + force=True, + interrupt=True, + ) + self.assertEqual(chromiumCalls, []) + self.assertEqual(testScript._steamActiveQuickAccessTab, "Friends") + + def test_selected_changed_defers_unknown_unnamed_quick_access_tab(self): + testScript = steam_script.Script.__new__(steam_script.Script) + source = object() + lastEvent = mock.Mock(event_string="Down", modifiers=0) + event = mock.Mock( + type="object:state-changed:selected", + source=source, + detail1=1, + any_data=None, + ) + + testScript.utilities = mock.Mock() + testScript.utilities.inDocumentContent.return_value = True + testScript.utilities.displayedText.return_value = "" + testScript.presentMessage = mock.Mock() + + chromiumCalls = [] + + def chromiumOnSelectedChanged(self, selectedEvent): + chromiumCalls.append((self, selectedEvent)) + return "handled" + + with ( + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=True, + ), + mock.patch.object(steam_script.Script, "_hasSteamQuickAccessMarker", return_value=True), + mock.patch.object(steam_script.cthulhu_state, "lastNonModifierKeyEvent", lastEvent), + mock.patch.object(steam_script.cthulhu_state, "lastInputEvent", None), + mock.patch.object(steam_script.AXObject, "get_name", return_value=""), + mock.patch.object( + steam_script.AXObject, + "get_attribute", + side_effect=lambda obj, name: "quickaccess_tab_unknown" if name == "id" else "", + ), + mock.patch.object(steam_script.AXUtilities, "is_page_tab", return_value=True), + mock.patch.object(steam_script.AXUtilities, "is_list_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_menu_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_button", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_push_button", return_value=False), + mock.patch.object( + steam_script.Chromium.Script, + "onSelectedChanged", + new=chromiumOnSelectedChanged, + ), + ): + self.assertEqual(testScript.onSelectedChanged(event), "handled") + + testScript.presentMessage.assert_not_called() + self.assertEqual(chromiumCalls, [(testScript, event)]) + + def test_selected_changed_defers_when_foreground_is_not_steam_game(self): + testScript = steam_script.Script.__new__(steam_script.Script) + source = object() + lastEvent = mock.Mock(event_string="Down", modifiers=0) + event = mock.Mock( + type="object:state-changed:selected", + source=source, + detail1=1, + any_data=None, + ) + + testScript.utilities = mock.Mock() + testScript.utilities.inDocumentContent.return_value = True + testScript.utilities.displayedText.return_value = "" + testScript.presentMessage = mock.Mock() + + chromiumCalls = [] + + def chromiumOnSelectedChanged(self, selectedEvent): + chromiumCalls.append((self, selectedEvent)) + return "handled" + + with ( + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=False, + ), + mock.patch.object(steam_script.cthulhu_state, "lastNonModifierKeyEvent", lastEvent), + mock.patch.object( + steam_script.Chromium.Script, + "onSelectedChanged", + new=chromiumOnSelectedChanged, + ), + ): + self.assertEqual(testScript.onSelectedChanged(event), "handled") + + testScript.presentMessage.assert_not_called() + self.assertEqual(chromiumCalls, [(testScript, event)]) + + def test_selection_changed_uses_selected_child_from_quick_access_container(self): + testScript = steam_script.Script.__new__(steam_script.Script) + source = object() + child = object() + lastEvent = mock.Mock(event_string=" ", modifiers=0) + event = mock.Mock( + type="object:selection-changed", + source=source, + detail1=0, + any_data=0, + ) + + testScript.utilities = mock.Mock() + testScript.utilities.inDocumentContent.return_value = True + testScript.utilities.displayedText.return_value = "" + testScript.presentMessage = mock.Mock() + testScript._lastSteamOverlaySelection = ("", 0.0) + + with ( + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=True, + ), + mock.patch.object(steam_script.Script, "_hasSteamQuickAccessMarker", return_value=True), + mock.patch.object(steam_script.cthulhu_state, "lastNonModifierKeyEvent", lastEvent), + mock.patch.object(steam_script.cthulhu_state, "lastInputEvent", None), + mock.patch.object(steam_script.AXSelection, "get_selected_children", return_value=[child]), + mock.patch.object(steam_script.AXObject, "get_name", side_effect=lambda obj: "Friends" if obj is child else ""), + mock.patch.object(steam_script.AXUtilities, "is_page_tab", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_list_item", side_effect=lambda obj: obj is child), + mock.patch.object(steam_script.AXUtilities, "is_menu_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_button", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_push_button", return_value=False), + ): + self.assertTrue(testScript.onSelectionChanged(event)) + + testScript.presentMessage.assert_called_once_with( + "Friends", + force=True, + interrupt=True, + ) + + def test_selected_changed_presents_steam_big_picture_tab_when_steam_is_foreground(self): + testScript = steam_script.Script.__new__(steam_script.Script) + source = object() + document = object() + lastEvent = mock.Mock(event_string="Right", modifiers=0) + event = mock.Mock( + type="object:state-changed:selected", + source=source, + detail1=1, + any_data=0, + ) + + testScript.utilities = mock.Mock() + testScript.utilities.inDocumentContent.return_value = True + testScript.utilities.displayedText.return_value = "ACTIVITY" + testScript.utilities.getTopLevelDocumentForObject.return_value = document + testScript.utilities.documentFrameURI.return_value = "https://steamloopback.host/index.html" + testScript.presentMessage = mock.Mock() + testScript._lastSteamOverlaySelection = ("", 0.0) + + chromiumCalls = [] + + def chromiumOnSelectedChanged(self, selectedEvent): + chromiumCalls.append((self, selectedEvent)) + return False + + with ( + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=True, + ), + mock.patch.object(steam_script.Script, "_hasSteamQuickAccessMarker", return_value=False), + mock.patch.object(steam_script.cthulhu_state, "lastNonModifierKeyEvent", lastEvent), + mock.patch.object(steam_script.cthulhu_state, "lastInputEvent", None), + mock.patch.object(steam_script.AXObject, "get_name", return_value="ACTIVITY"), + mock.patch.object(steam_script.AXUtilities, "is_page_tab", return_value=True), + mock.patch.object(steam_script.AXUtilities, "is_list_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_menu_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_button", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_push_button", return_value=False), + mock.patch.object( + steam_script.Chromium.Script, + "onSelectedChanged", + new=chromiumOnSelectedChanged, + ), + ): + self.assertTrue(testScript.onSelectedChanged(event)) + + testScript.presentMessage.assert_called_once_with( + "ACTIVITY", + force=True, + interrupt=True, + ) + self.assertEqual(chromiumCalls, []) + + def test_selected_changed_defers_steam_tab_outside_steam_big_picture_document(self): + testScript = steam_script.Script.__new__(steam_script.Script) + source = object() + document = object() + lastEvent = mock.Mock(event_string="Right", modifiers=0) + event = mock.Mock( + type="object:state-changed:selected", + source=source, + detail1=1, + any_data=0, + ) + + testScript.utilities = mock.Mock() + testScript.utilities.inDocumentContent.return_value = True + testScript.utilities.displayedText.return_value = "ACTIVITY" + testScript.utilities.getTopLevelDocumentForObject.return_value = document + testScript.utilities.documentFrameURI.return_value = "https://store.steampowered.com/" + testScript.presentMessage = mock.Mock() + + chromiumCalls = [] + + def chromiumOnSelectedChanged(self, selectedEvent): + chromiumCalls.append((self, selectedEvent)) + return "handled" + + with ( + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=True, + ), + mock.patch.object(steam_script.Script, "_hasSteamQuickAccessMarker", return_value=False), + mock.patch.object(steam_script.cthulhu_state, "lastNonModifierKeyEvent", lastEvent), + mock.patch.object(steam_script.cthulhu_state, "lastInputEvent", None), + mock.patch.object(steam_script.AXObject, "get_name", return_value="ACTIVITY"), + mock.patch.object(steam_script.AXUtilities, "is_page_tab", return_value=True), + mock.patch.object(steam_script.AXUtilities, "is_list_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_menu_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_button", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_push_button", return_value=False), + mock.patch.object( + steam_script.Chromium.Script, + "onSelectedChanged", + new=chromiumOnSelectedChanged, + ), + ): + self.assertEqual(testScript.onSelectedChanged(event), "handled") + + testScript.presentMessage.assert_not_called() + self.assertEqual(chromiumCalls, [(testScript, event)]) + + def test_x11_steam_game_class_detects_steam_app_identifier(self): + classGroup = mock.Mock() + classGroup.get_name.return_value = "steam_app_1281930" + classGroup.get_res_class.return_value = "steam_app_1281930" + window = mock.Mock() + window.get_class_group_name.return_value = "" + window.get_class_instance_name.return_value = "" + window.get_class_group.return_value = classGroup + + self.assertTrue(steam_script.Script._x11WindowHasSteamGameClass(window)) + + def test_x11_steam_game_class_rejects_plain_chromium_window(self): + classGroup = mock.Mock() + classGroup.get_name.return_value = "chromium" + classGroup.get_res_class.return_value = "Chromium" + window = mock.Mock() + window.get_class_group_name.return_value = "Chromium" + window.get_class_instance_name.return_value = "chromium" + window.get_class_group.return_value = classGroup + + self.assertFalse(steam_script.Script._x11WindowHasSteamGameClass(window)) + + def test_x11_steam_big_picture_window_is_selection_context(self): + classGroup = mock.Mock() + classGroup.get_name.return_value = "steam" + classGroup.get_res_class.return_value = "steam" + window = mock.Mock() + window.get_name.return_value = "Steam Big Picture Mode" + window.get_class_group_name.return_value = "steam" + window.get_class_instance_name.return_value = "steamwebhelper" + window.get_class_group.return_value = classGroup + + self.assertTrue(steam_script.Script._x11WindowIsSteamBigPicture(window)) + + def test_x11_plain_steam_window_is_not_big_picture_selection_context(self): + classGroup = mock.Mock() + classGroup.get_name.return_value = "steam" + classGroup.get_res_class.return_value = "steam" + window = mock.Mock() + window.get_name.return_value = "Steam" + window.get_class_group_name.return_value = "steam" + window.get_class_instance_name.return_value = "steamwebhelper" + window.get_class_group.return_value = classGroup + + self.assertFalse(steam_script.Script._x11WindowIsSteamBigPicture(window)) + + class SteamReturnActivationTests(unittest.TestCase): def test_return_activates_focused_steam_button(self): testScript = steam_script.Script.__new__(steam_script.Script) @@ -94,8 +497,283 @@ class SteamReturnActivationTests(unittest.TestCase): performAction.assert_called_once_with(button) + def test_return_activates_last_spoken_friends_panel_button(self): + testScript = steam_script.Script.__new__(steam_script.Script) + button = object() + keyboardEvent = mock.Mock(event_string="Return", modifiers=0) + keyboardEvent.is_pressed_key.return_value = True + + testScript.utilities = mock.Mock() + testScript.utilities.inDocumentContent.return_value = True + testScript._steamActiveQuickAccessTab = "Friends" + testScript._steamFriendsPanelActivationTarget = ( + button, + "Username Playing Terraria", + 100.0, + ) + testScript._steamFriendsPanelActivationTargetMaxAge = 10.0 + + def has_action(obj, action_name): + return obj is button and action_name == "press" + + with ( + mock.patch.object(steam_script.time, "monotonic", return_value=105.0), + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=True, + ), + mock.patch.object(steam_script.Script, "_hasSteamQuickAccessMarker", return_value=True), + mock.patch.object(testScript, "_getClickableActivationTarget", return_value=None), + mock.patch.object(steam_script.AXObject, "is_dead", return_value=False), + mock.patch.object(steam_script.AXObject, "find_ancestor_inclusive", return_value=None), + mock.patch.object(steam_script.AXUtilities, "is_page_tab", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_page_tab_list", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_list_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_button", return_value=True), + mock.patch.object(steam_script.AXUtilities, "is_push_button", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_table_row", return_value=False), + mock.patch.object(steam_script.AXObject, "has_action", side_effect=has_action), + mock.patch.object(steam_script.Script, "_performClickableAction", return_value=True) as performAction, + ): + self.assertTrue(testScript.shouldConsumeKeyboardEvent(keyboardEvent, None)) + + performAction.assert_called_once_with(button) + + def test_return_defers_stale_friends_panel_activation_target(self): + testScript = steam_script.Script.__new__(steam_script.Script) + button = object() + keyboardEvent = mock.Mock(event_string="Return", modifiers=0) + keyboardEvent.is_pressed_key.return_value = True + + testScript.utilities = mock.Mock() + testScript._steamActiveQuickAccessTab = "Friends" + testScript._steamFriendsPanelActivationTarget = ( + button, + "Username Playing Terraria", + 100.0, + ) + testScript._steamFriendsPanelActivationTargetMaxAge = 10.0 + + with ( + mock.patch.object(steam_script.time, "monotonic", return_value=111.0), + mock.patch.object(steam_script.debug, "printMessage"), + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=True, + ), + mock.patch.object(testScript, "_getClickableActivationTarget", return_value=None), + ): + self.assertFalse(testScript.shouldConsumeKeyboardEvent(keyboardEvent, None)) + + self.assertEqual(testScript._steamFriendsPanelActivationTarget, (None, "", 0.0)) + class SteamVirtualizedListMutationTests(unittest.TestCase): + def test_name_changed_presents_friends_panel_item_after_friends_tab_activation(self): + testScript = steam_script.Script.__new__(steam_script.Script) + row = object() + lastEvent = mock.Mock(event_string="Down", modifiers=0) + event = mock.Mock( + type="object:property-change:accessible-name", + source=row, + any_data="Username Playing Terraria", + detail1=0, + ) + + testScript.utilities = mock.Mock() + testScript.utilities.displayedText.side_effect = ( + lambda obj: "Username Playing Terraria" if obj is row else "" + ) + testScript.presentMessage = mock.Mock() + testScript._lastSteamOverlaySelection = ("", 0.0) + testScript._steamActiveQuickAccessTab = "Friends" + + chromiumCalls = [] + + def chromiumOnNameChanged(self, nameEvent): + chromiumCalls.append((self, nameEvent)) + return False + + with ( + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=True, + ), + mock.patch.object(steam_script.Script, "_hasSteamQuickAccessMarker", return_value=True), + mock.patch.object(steam_script.cthulhu_state, "lastNonModifierKeyEvent", lastEvent), + mock.patch.object(steam_script.cthulhu_state, "lastInputEvent", None), + mock.patch.object(steam_script.AXUtilities, "is_page_tab", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_page_tab_list", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_list_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_button", return_value=True), + mock.patch.object(steam_script.AXUtilities, "is_push_button", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_table_row", return_value=False), + mock.patch.object(steam_script.AXObject, "find_ancestor_inclusive", return_value=None), + mock.patch.object( + steam_script.Chromium.Script, + "onNameChanged", + new=chromiumOnNameChanged, + ), + ): + self.assertTrue(testScript.onNameChanged(event)) + + testScript.presentMessage.assert_called_once_with( + "Username Playing Terraria", + force=True, + interrupt=True, + ) + self.assertEqual( + testScript._steamFriendsPanelActivationTarget[:2], + (row, "Username Playing Terraria"), + ) + self.assertEqual(chromiumCalls, []) + + def test_children_added_defers_friends_panel_load_artifact(self): + testScript = steam_script.Script.__new__(steam_script.Script) + source = object() + row = object() + lastEvent = mock.Mock(event_string="Down", modifiers=0) + event = mock.Mock( + type="object:children-changed:add", + source=source, + any_data=row, + detail1=0, + ) + + testScript.utilities = mock.Mock() + testScript.utilities.isSteamVirtualizedList.return_value = False + testScript.presentMessage = mock.Mock() + testScript._steamActiveQuickAccessTab = "Friends" + + chromiumCalls = [] + + def chromiumOnChildrenAdded(self, addedEvent): + chromiumCalls.append((self, addedEvent)) + return False + + with ( + mock.patch.object(steam_script.Script, "_isSteamNotification", return_value=False), + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=True, + ), + mock.patch.object(steam_script.cthulhu_state, "lastNonModifierKeyEvent", lastEvent), + mock.patch.object(steam_script.cthulhu_state, "lastInputEvent", None), + mock.patch.object( + steam_script.Chromium.Script, + "onChildrenAdded", + new=chromiumOnChildrenAdded, + ), + ): + testScript.onChildrenAdded(event) + + testScript.presentMessage.assert_not_called() + self.assertEqual(chromiumCalls, [(testScript, event)]) + + def test_name_changed_ignores_add_friend_panel_button(self): + testScript = steam_script.Script.__new__(steam_script.Script) + button = object() + lastEvent = mock.Mock(event_string="Down", modifiers=0) + event = mock.Mock( + type="object:property-change:accessible-name", + source=button, + any_data="Add Friend", + detail1=0, + ) + + testScript.utilities = mock.Mock() + testScript.utilities.displayedText.return_value = "Add Friend" + testScript.presentMessage = mock.Mock() + testScript._steamActiveQuickAccessTab = "Friends" + + chromiumCalls = [] + + def chromiumOnNameChanged(self, nameEvent): + chromiumCalls.append((self, nameEvent)) + return "handled" + + with ( + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=True, + ), + mock.patch.object(steam_script.Script, "_hasSteamQuickAccessMarker", return_value=True), + mock.patch.object(steam_script.cthulhu_state, "lastNonModifierKeyEvent", lastEvent), + mock.patch.object(steam_script.cthulhu_state, "lastInputEvent", None), + mock.patch.object(steam_script.AXUtilities, "is_page_tab", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_page_tab_list", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_list_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_button", return_value=True), + mock.patch.object(steam_script.AXUtilities, "is_push_button", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_table_row", return_value=False), + mock.patch.object(steam_script.AXObject, "find_ancestor_inclusive", return_value=None), + mock.patch.object( + steam_script.Chromium.Script, + "onNameChanged", + new=chromiumOnNameChanged, + ), + ): + self.assertEqual(testScript.onNameChanged(event), "handled") + + testScript.presentMessage.assert_not_called() + self.assertEqual(chromiumCalls, [(testScript, event)]) + + def test_name_changed_ignores_friends_panel_chat_history_button(self): + testScript = steam_script.Script.__new__(steam_script.Script) + button = object() + lastEvent = mock.Mock(event_string="Down", modifiers=0) + event = mock.Mock( + type="object:property-change:accessible-name", + source=button, + any_data="LOADING OLDER MESSAGES... Username is typing a message...", + detail1=0, + ) + + testScript.utilities = mock.Mock() + testScript.utilities.displayedText.return_value = ( + "LOADING OLDER MESSAGES... Username is typing a message..." + ) + testScript.presentMessage = mock.Mock() + testScript._steamActiveQuickAccessTab = "Friends" + + chromiumCalls = [] + + def chromiumOnNameChanged(self, nameEvent): + chromiumCalls.append((self, nameEvent)) + return "handled" + + with ( + mock.patch.object( + steam_script.Script, + "_activeX11WindowIsSteamSelectionContext", + return_value=True, + ), + mock.patch.object(steam_script.Script, "_hasSteamQuickAccessMarker", return_value=True), + mock.patch.object(steam_script.cthulhu_state, "lastNonModifierKeyEvent", lastEvent), + mock.patch.object(steam_script.cthulhu_state, "lastInputEvent", None), + mock.patch.object(steam_script.AXUtilities, "is_page_tab", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_page_tab_list", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_list_item", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_button", return_value=True), + mock.patch.object(steam_script.AXUtilities, "is_push_button", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_table_row", return_value=False), + mock.patch.object(steam_script.AXObject, "find_ancestor_inclusive", return_value=None), + mock.patch.object( + steam_script.Chromium.Script, + "onNameChanged", + new=chromiumOnNameChanged, + ), + ): + self.assertEqual(testScript.onNameChanged(event), "handled") + + testScript.presentMessage.assert_not_called() + self.assertEqual(chromiumCalls, [(testScript, event)]) + def test_caret_moved_ignores_transient_loading_spinner(self): testScript = steam_script.Script.__new__(steam_script.Script) source = object()