Some web work. Firefox still being weird.

This commit is contained in:
Storm Dragon
2026-07-06 18:22:19 -04:00
parent 3926ed0044
commit 323d0a3975
11 changed files with 584 additions and 1 deletions
+26
View File
@@ -1042,6 +1042,32 @@ class Cthulhu(GObject.Object):
def getLogger(self) -> logger.Logger: # New getter for the logger
return self.logger
def get_debug_snapshot(self) -> Dict[str, Dict[str, object]]:
"""Returns read-only runtime counters useful for diagnostics."""
speechServer = speech.getSpeechServer()
snapshot: Dict[str, Dict[str, object]] = {
"cthulhu_state": {
"active_window_present": cthulhu_state.activeWindow is not None,
"locus_of_focus_present": cthulhu_state.locusOfFocus is not None,
"active_script_present": cthulhu_state.activeScript is not None,
"pending_self_hosted_focus_present": cthulhu_state.pendingSelfHostedFocus is not None,
"device_present": cthulhu_state.device is not None,
"pause_atspi_churn": cthulhu_state.pauseAtspiChurn,
"prioritized_desktop_context_token_present":
cthulhu_state.prioritizedDesktopContextToken is not None,
},
"speech": {
"speech_server_present": speechServer is not None,
"speech_server": speechServer.__class__.__name__ if speechServer else "",
},
"event_manager": self.eventManager.get_debug_snapshot(),
"script_manager": self.scriptManager.get_debug_snapshot(),
"input_event_manager": input_event_manager.get_manager().get_debug_snapshot(),
"plugin_system_manager": self.pluginSystemManager.get_debug_snapshot(),
}
return snapshot
def addKeyGrab(self, binding: Any) -> List[int]:
return addKeyGrab(binding)
+11
View File
@@ -698,6 +698,17 @@ class CthulhuDBusServiceInterface(Publishable):
debug.print_message(debug.LEVEL_INFO, msg, True)
return result
def GetRuntimeStateSnapshot(self) -> str: # pylint: disable=invalid-name
"""Returns read-only runtime state counters for diagnostics."""
msg = "DBUS SERVICE: GetRuntimeStateSnapshot called."
debug.print_message(debug.LEVEL_INFO, msg, True)
from . import cthulhu # pylint: disable=import-outside-toplevel
snapshot = cthulhu.cthulhuApp.get_debug_snapshot()
return debug.format_runtime_state_snapshot(snapshot)
def Quit(self) -> bool: # pylint: disable=invalid-name
"""Quits Cthulhu."""
+15 -1
View File
@@ -45,7 +45,7 @@ import subprocess
import sys
import time
import types
from typing import Any, Generator, Optional, TextIO, Pattern, TYPE_CHECKING
from typing import Any, Generator, Mapping, Optional, TextIO, Pattern, TYPE_CHECKING
from datetime import datetime
import gi
@@ -291,6 +291,20 @@ def print_log_tokens(level: int, prefix: str, tokens: list[Any], reason: Optiona
text = format_log_message(prefix, _format_tokens(tokens), reason)
printMessage(level, text, timestamp, stack)
def format_runtime_state_snapshot(snapshot: Mapping[str, Any]) -> str:
"""Formats read-only runtime state counters for debug capture."""
lines = ["CTHULHU RUNTIME STATE SNAPSHOT"]
for section, values in snapshot.items():
lines.append(f"[{section}]")
if not isinstance(values, Mapping):
lines.append(f"value: {values}")
continue
for key in sorted(values):
lines.append(f"{key}: {values[key]}")
return "\n".join(lines)
def reset_startup_timing() -> None:
"""Reset the startup timing baseline."""
+24
View File
@@ -194,6 +194,30 @@ class EventManager:
self._deactivateKeyHandling()
debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Deactivated', True)
def get_debug_snapshot(self) -> Dict[str, object]:
"""Returns read-only counters useful for long-uptime diagnostics."""
return {
"active": self._active,
"async_mode": self._asyncMode,
"event_queue_size": self._eventQueue.qsize(),
"prioritized_event_present": self._prioritizedEvent is not None,
"idle_source_id": self._gidleId,
"prioritized_idle_source_id": self._prioritizedIdleId,
"events_suspended": self._eventsSuspended,
"suspendable_event_types": len(self._suspendableEvents),
"events_triggering_suspension": len(self._eventsTriggeringSuspension),
"ignored_event_types": len(self._ignoredEvents),
"script_listener_types": len(self._scriptListenerCounts),
"parents_of_defunct_descendants": len(self._parentsOfDefunctDescendants),
"cmdline_cache_size": len(self._cmdlineCache),
"relevance_burst_history_size": len(self._relevanceBurstHistory),
"churn_suppressed": self._churnSuppressed,
"prioritized_context_token_present": self._prioritizedContextToken is not None,
"key_handling_active": self._keyHandlingActive,
"input_event_manager_present": self._inputEventManager is not None,
}
def set_compositor_state_adapter(self, adapter: Any) -> None:
"""Stores the compositor state adapter used for startup wiring."""
+15
View File
@@ -109,6 +109,21 @@ class InputEventManager:
debug.print_message(debug.LEVEL_INFO, msg, True)
self._device = None
def get_debug_snapshot(self) -> Dict[str, object]:
"""Returns read-only counters useful for long-uptime diagnostics."""
return {
"device_active": self._device is not None,
"pointer_watcher_active": self._pointer_moved_id != 0,
"mapped_keycodes": len(self._mapped_keycodes),
"mapped_keysyms": len(self._mapped_keysyms),
"grabbed_bindings": len(self._grabbed_bindings),
"paused": self._paused,
"suspended_xterm_script_present": self._scriptWithSuspendedGrabsForXterm is not None,
"last_input_event_present": self._last_input_event is not None,
"last_non_modifier_key_event_present": self._last_non_modifier_key_event is not None,
}
def enable_pointer_monitoring(self) -> bool:
"""Enables pointer monitoring on the current device, if possible."""
+22
View File
@@ -1102,3 +1102,25 @@ class PluginSystemManager:
for pluginInfo in self.plugins:
if ForceAllPlugins or pluginInfo.loaded:
self.unloadPlugin(pluginInfo)
def get_debug_snapshot(self) -> Dict[str, object]:
"""Returns read-only counters useful for long-uptime diagnostics."""
loadedPlugins = [info for info in self._plugins.values() if info.loaded]
try:
pluggyRegisteredPlugins = len(self.plugin_manager.get_plugins())
except Exception:
pluggyRegisteredPlugins = -1
return {
"scanned_plugins": len(self._plugins),
"active_plugins": len(self._active_plugins),
"loaded_plugins": len(loadedPlugins),
"builtin_plugins": sum(1 for info in self._plugins.values() if info.builtin),
"hidden_plugins": sum(1 for info in self._plugins.values() if info.hidden),
"plugin_keybinding_contexts": len(self._plugin_keybindings),
"plugin_keybindings": sum(len(bindings) for bindings in self._plugin_keybindings.values()),
"global_keybindings": len(self._global_bindings),
"last_active_script_present": self._last_active_script is not None,
"pluggy_registered_plugins": pluggyRegisteredPlugins,
}
+25
View File
@@ -396,6 +396,31 @@ class ScriptManager:
reason
)
def get_debug_snapshot(self) -> Dict[str, object]:
"""Returns read-only counters useful for long-uptime diagnostics."""
activeScript = cthulhu_state.activeScript
activeApp = getattr(activeScript, "app", None)
activeAppName = ""
if activeApp is not None:
try:
activeAppName = AXObject.get_name(activeApp) or ""
except Exception:
activeAppName = "[unavailable]"
return {
"active": self._active,
"active_script": activeScript.__class__.__name__ if activeScript else "",
"active_script_app": activeAppName,
"default_script_present": self._defaultScript is not None,
"app_scripts": len(self.appScripts),
"toolkit_script_apps": len(self.toolkitScripts),
"toolkit_scripts": sum(len(scripts) for scripts in self.toolkitScripts.values()),
"custom_script_apps": len(self.customScripts),
"custom_scripts": sum(len(scripts) for scripts in self.customScripts.values()),
"sleep_mode_scripts": len(self._sleepModeScripts),
}
def _get_script_for_app_replicant(self, app: Atspi.Accessible) -> Optional[Script]:
if not self._active:
return None
+19
View File
@@ -2116,6 +2116,11 @@ class Script(default.Script):
else:
self._clearSyntheticWebSelection()
if self.utilities.lastInputEventWasCharNav() and (event.source, event.detail1) == (obj, offset):
msg = "WEB: Event ignored: Character navigation already presented this context"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self._lastCommandWasCaretNav:
msg = "WEB: Event ignored: Last command was caret nav"
debug.printMessage(debug.LEVEL_INFO, msg, True)
@@ -2624,6 +2629,20 @@ class Script(default.Script):
self.utilities.setCaretContext(event.source, 0)
obj, offset = event.source, 0
if self._lastCommandWasCaretNav \
and obj is not event.source \
and AXUtilities.is_focusable(event.source) \
and AXUtilities.is_focused(event.source) \
and self.utilities.inDocumentContent(event.source) \
and self.utilities.isLink(event.source) \
and obj \
and AXUtilities.is_section(obj):
msg = "WEB: Event handled: Replacing stale section context with focused link"
debug.printMessage(debug.LEVEL_INFO, msg, True)
cthulhu.setLocusOfFocus(event, event.source, False)
self.utilities.setCaretContext(event.source, 0)
return True
if self._lastCommandWasCaretNav:
msg = "WEB: Event ignored: Last command was caret nav"
debug.printMessage(debug.LEVEL_INFO, msg, True)
@@ -230,6 +230,7 @@ class Utilities(script_utilities.Utilities):
self._shouldInferLabelFor = {}
self._treatAsTextObject = {}
self._treatAsDiv = {}
self.clearContentCache()
self._paths = {}
self._contextPathsRolesAndNames = {}
self._canHaveCaretContextDecision = {}
@@ -1793,6 +1794,21 @@ class Utilities(script_utilities.Utilities):
self._canHaveCaretContextDecision = {}
return rv
def _trimContentsBeforeEmbeddedObjectBoundary(self, contents, obj, offset):
if not contents:
return contents
firstObj, firstStart, firstEnd, firstString = contents[0]
if firstObj != obj or not (firstStart < offset < firstEnd):
return contents
char = AXText.get_character_at_offset(obj, firstStart)[0]
if char != self.EMBEDDED_OBJECT_CHARACTER:
return contents
string = AXText.get_substring(obj, offset, firstEnd)
return [[firstObj, offset, firstEnd, string]] + contents[1:]
def _getLineContentsAtOffset(self, obj, offset, layoutMode=None, useCache=True):
startTime = time.time()
if not obj:
@@ -1881,6 +1897,13 @@ class Utilities(script_utilities.Utilities):
self._debugContentsInfo(obj, offset, objects, "Line (not layout mode)")
return objects
if self.isLink(obj) and objects and objects[0][0] == obj and objects[0][1] > 0:
if useCache:
self._currentLineContents = objects
self._debugContentsInfo(obj, offset, objects, "Line (wrapped link)")
return objects
firstObj, firstStart, firstEnd, firstString = objects[0]
if (extents[2] == 0 and extents[3] == 0) or self.isMath(firstObj):
extents = self.getExtents(firstObj, firstStart, firstEnd)
@@ -2027,6 +2050,13 @@ class Utilities(script_utilities.Utilities):
tokens = ["WEB: Previous context is: ", obj, ", ", offset]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
if obj == firstObj and 0 <= offset < firstOffset and self.isLink(obj):
contents = self.getLineContentsAtOffset(obj, offset, layoutMode=False, useCache=False)
if contents and contents != line:
msg = "WEB: Using same-link non-layout previous line contents."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return contents
contents = self.getLineContentsAtOffset(obj, offset, layoutMode, useCache)
if not contents:
tokens = ["WEB: Could not get line contents for ", obj, ", ", offset]
@@ -2092,6 +2122,23 @@ class Utilities(script_utilities.Utilities):
tokens = ["WEB: Next context is: ", obj, ", ", offset]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
if obj == lastObj and offset > lastOffset and self.isLink(obj):
contents = self.getLineContentsAtOffset(obj, offset, layoutMode=False, useCache=False)
if contents and contents != line:
msg = "WEB: Using same-link non-layout next line contents."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return contents
if self.isLink(lastObj):
start, end = self.getHyperlinkRange(lastObj)
if obj == AXObject.get_parent(lastObj) and end >= 0 and offset >= end:
contents = self.getLineContentsAtOffset(obj, offset, layoutMode=False, useCache=False)
contents = self._trimContentsBeforeEmbeddedObjectBoundary(contents, obj, offset)
if contents and contents != line:
msg = "WEB: Using non-layout line after split link."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return contents
contents = self.getLineContentsAtOffset(obj, offset, layoutMode, useCache)
if line == contents:
obj, offset = self.nextContext(obj, offset, True)
+148
View File
@@ -0,0 +1,148 @@
import queue
import sys
import types
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import cthulhu_state
from cthulhu import debug
stubCthulhu = types.ModuleType("cthulhu.cthulhu")
stubCthulhu.cthulhuApp = mock.Mock()
sys.modules.setdefault("cthulhu.cthulhu", stubCthulhu)
from cthulhu import event_manager
from cthulhu.input_event_manager import InputEventManager
from cthulhu.plugin_system_manager import PluginSystemManager
from cthulhu.script_manager import ScriptManager
class RuntimeStateSnapshotTests(unittest.TestCase):
def test_formatter_sorts_keys_and_groups_sections(self) -> None:
result = debug.format_runtime_state_snapshot({
"event_manager": {"z_count": 2, "a_count": 1},
"speech": {"speech_server_present": False},
})
self.assertEqual(
result,
"\n".join([
"CTHULHU RUNTIME STATE SNAPSHOT",
"[event_manager]",
"a_count: 1",
"z_count: 2",
"[speech]",
"speech_server_present: False",
]),
)
def test_event_manager_snapshot_reports_core_counters(self) -> None:
manager = event_manager.EventManager.__new__(event_manager.EventManager)
manager._active = True
manager._asyncMode = True
manager._eventQueue = queue.Queue()
manager._eventQueue.put(object())
manager._prioritizedEvent = object()
manager._gidleId = 11
manager._prioritizedIdleId = 12
manager._eventsSuspended = True
manager._suspendableEvents = ["object:children-changed:add"]
manager._eventsTriggeringSuspension = [object(), object()]
manager._ignoredEvents = ["object:bounds-changed"]
manager._scriptListenerCounts = {"focus:": 1}
manager._parentsOfDefunctDescendants = [object()]
manager._cmdlineCache = {123: "app"}
manager._relevanceBurstHistory = {("a", "b", "c"): 1.0}
manager._churnSuppressed = True
manager._prioritizedContextToken = "token"
manager._keyHandlingActive = True
manager._inputEventManager = object()
snapshot = manager.get_debug_snapshot()
self.assertEqual(1, snapshot["event_queue_size"])
self.assertTrue(snapshot["prioritized_event_present"])
self.assertEqual(11, snapshot["idle_source_id"])
self.assertEqual(2, snapshot["events_triggering_suspension"])
self.assertEqual(1, snapshot["cmdline_cache_size"])
self.assertTrue(snapshot["prioritized_context_token_present"])
def test_input_event_manager_snapshot_reports_grab_state(self) -> None:
manager = InputEventManager()
manager._device = object()
manager._pointer_moved_id = 42
manager._mapped_keycodes = [1, 2]
manager._mapped_keysyms = [3]
manager._grabbed_bindings = {10: object(), 11: object()}
manager._paused = True
manager._scriptWithSuspendedGrabsForXterm = object()
manager._last_input_event = object()
manager._last_non_modifier_key_event = None
snapshot = manager.get_debug_snapshot()
self.assertTrue(snapshot["device_active"])
self.assertTrue(snapshot["pointer_watcher_active"])
self.assertEqual(2, snapshot["mapped_keycodes"])
self.assertEqual(1, snapshot["mapped_keysyms"])
self.assertEqual(2, snapshot["grabbed_bindings"])
self.assertTrue(snapshot["suspended_xterm_script_present"])
self.assertFalse(snapshot["last_non_modifier_key_event_present"])
def test_script_manager_snapshot_reports_cache_counts(self) -> None:
activeScript = SimpleNamespace(app=None)
oldActiveScript = cthulhu_state.activeScript
cthulhu_state.activeScript = activeScript
try:
manager = ScriptManager.__new__(ScriptManager)
manager._active = True
manager._defaultScript = object()
manager.appScripts = {"app": object()}
manager.toolkitScripts = {"app": {"toolkit": object()}}
manager.customScripts = {"app": {"role": object(), "other": object()}}
manager._sleepModeScripts = {"app": object()}
snapshot = manager.get_debug_snapshot()
finally:
cthulhu_state.activeScript = oldActiveScript
self.assertTrue(snapshot["active"])
self.assertEqual("SimpleNamespace", snapshot["active_script"])
self.assertTrue(snapshot["default_script_present"])
self.assertEqual(1, snapshot["app_scripts"])
self.assertEqual(1, snapshot["toolkit_scripts"])
self.assertEqual(2, snapshot["custom_scripts"])
self.assertEqual(1, snapshot["sleep_mode_scripts"])
def test_plugin_system_manager_snapshot_reports_plugin_counts(self) -> None:
manager = PluginSystemManager.__new__(PluginSystemManager)
manager._plugins = {
"Loaded": SimpleNamespace(loaded=True, builtin=False, hidden=False),
"Builtin": SimpleNamespace(loaded=True, builtin=True, hidden=False),
"Hidden": SimpleNamespace(loaded=False, builtin=False, hidden=True),
}
manager._active_plugins = ["Loaded", "Builtin"]
manager._plugin_keybindings = {"Loaded": [object(), object()]}
manager._global_bindings = [object()]
manager._last_active_script = object()
manager.plugin_manager = mock.Mock()
manager.plugin_manager.get_plugins.return_value = [object(), object()]
snapshot = manager.get_debug_snapshot()
self.assertEqual(3, snapshot["scanned_plugins"])
self.assertEqual(2, snapshot["active_plugins"])
self.assertEqual(2, snapshot["loaded_plugins"])
self.assertEqual(1, snapshot["builtin_plugins"])
self.assertEqual(1, snapshot["hidden_plugins"])
self.assertEqual(2, snapshot["plugin_keybindings"])
self.assertEqual(1, snapshot["global_keybindings"])
self.assertEqual(2, snapshot["pluggy_registered_plugins"])
if __name__ == "__main__":
unittest.main()
+232
View File
@@ -329,6 +329,34 @@ class WebFocusSpeechInterruptionRegressionTests(unittest.TestCase):
speak.assert_called_once_with(["focus speech"], interrupt=False)
class WebCaretMovedRegressionTests(unittest.TestCase):
def _make_script(self):
testScript = web_script.Script.__new__(web_script.Script)
testScript._lastCommandWasCaretNav = False
testScript._lastCommandWasStructNav = False
testScript._lastCommandWasMouseButton = False
testScript._clearSyntheticWebSelection = mock.Mock()
testScript.utilities = mock.Mock()
testScript.utilities.isZombie.return_value = False
testScript.utilities.getTopLevelDocumentForObject.return_value = "document"
testScript.utilities.getCaretContext.return_value = ("source", 4)
testScript.utilities.lastInputEventWasCaretNavWithSelection.return_value = False
testScript.utilities.lastInputEventWasCharNav.return_value = True
return testScript
def test_matching_char_nav_caret_event_is_consumed_without_duplicate_presentation(self):
testScript = self._make_script()
source = "source"
event = mock.Mock(source=source, detail1=4)
with mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus:
result = web_script.Script.onCaretMoved(testScript, event)
self.assertTrue(result)
testScript.utilities.setCaretContext.assert_not_called()
setLocusOfFocus.assert_not_called()
class WebDescriptionChangeRegressionTests(unittest.TestCase):
def _make_script(self):
testScript = web_script.Script.__new__(web_script.Script)
@@ -452,6 +480,35 @@ class WebDynamicContentRecoveryRegressionTests(unittest.TestCase):
setLocusOfFocus.assert_called_once_with(event, source, False)
testScript.utilities.setCaretContext.assert_called_once_with(source, 0)
def test_focused_link_replaces_stale_section_context_after_caret_nav(self):
testScript = self._make_dynamic_script()
document = object()
section = object()
link = object()
event = mock.Mock(detail1=1, source=link)
testScript._lastCommandWasCaretNav = True
testScript.utilities.getDocumentForObject.return_value = document
testScript.utilities.isWebAppDescendant.return_value = False
testScript.utilities.handleEventFromContextReplicant.return_value = False
testScript.utilities.getCaretContext.return_value = (section, 4)
testScript.utilities.isLink.side_effect = lambda obj: obj is link
with (
mock.patch.object(web_script.cthulhu_state, "locusOfFocus", section),
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_section", side_effect=lambda obj: obj is section),
mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
):
result = web_script.Script.onFocusedChanged(testScript, event)
self.assertTrue(result)
setLocusOfFocus.assert_called_once_with(event, link, False)
testScript.utilities.setCaretContext.assert_called_once_with(link, 0)
testScript.utilities.searchForCaretContext.assert_not_called()
def test_browse_mode_sticky_blocks_web_app_descendant_focus_claim(self):
testScript = self._make_dynamic_script()
source = object()
@@ -635,3 +692,178 @@ class WebSelectionRegressionTests(unittest.TestCase):
result = web_script_utilities.Utilities.allSelectedText(utilities, obj)
self.assertEqual(result, ["Dark", 0, 4])
class WebContentCacheRegressionTests(unittest.TestCase):
def test_clear_cached_objects_clears_current_line_contents(self):
utilities = web_script_utilities.Utilities.__new__(web_script_utilities.Utilities)
utilities._currentObjectContents = ["object"]
utilities._currentSentenceContents = ["sentence"]
utilities._currentLineContents = ["line"]
utilities._currentWordContents = ["word"]
utilities._currentCharacterContents = ["character"]
utilities._currentTextAttrs = {"attrs": True}
utilities._cleanupContexts = mock.Mock()
web_script_utilities.Utilities.clearCachedObjects(utilities)
self.assertIsNone(utilities._currentObjectContents)
self.assertIsNone(utilities._currentSentenceContents)
self.assertIsNone(utilities._currentLineContents)
self.assertIsNone(utilities._currentWordContents)
self.assertIsNone(utilities._currentCharacterContents)
self.assertEqual(utilities._currentTextAttrs, {})
utilities._cleanupContexts.assert_called_once_with()
class WebSplitLinkLineNavigationRegressionTests(unittest.TestCase):
def _make_utilities(self, link):
utilities = web_script_utilities.Utilities.__new__(web_script_utilities.Utilities)
utilities.isZombie = mock.Mock(return_value=False)
utilities.getMathAncestor = mock.Mock(return_value=None)
utilities.isLink = mock.Mock(side_effect=lambda obj: obj is link)
utilities.clearCachedObjects = mock.Mock()
return utilities
def test_next_line_uses_non_layout_contents_for_split_link(self):
link = object()
utilities = self._make_utilities(link)
currentLine = [[link, 0, 20, "https://stormux.org/"]]
nextLine = [[link, 20, 29, "downloads"]]
utilities.nextContext = mock.Mock(return_value=(link, 20))
utilities.getLineContentsAtOffset = mock.Mock(side_effect=[currentLine, nextLine])
manager = mock.Mock()
manager.get_speak_blank_lines.return_value = False
with mock.patch.object(
web_script_utilities.speech_and_verbosity_manager,
"getManager",
return_value=manager,
):
result = web_script_utilities.Utilities.getNextLineContents(utilities, link, 0)
self.assertEqual(result, nextLine)
utilities.nextContext.assert_called_once_with(link, 19, True)
self.assertEqual(
utilities.getLineContentsAtOffset.call_args_list,
[
mock.call(link, 0, None, True),
mock.call(link, 20, layoutMode=False, useCache=False),
],
)
def test_previous_line_uses_non_layout_contents_for_split_link(self):
link = object()
utilities = self._make_utilities(link)
previousLine = [[link, 0, 20, "https://stormux.org/"]]
currentLine = [[link, 20, 29, "downloads"]]
utilities.previousContext = mock.Mock(return_value=(link, 19))
utilities.getLineContentsAtOffset = mock.Mock(side_effect=[currentLine, previousLine])
manager = mock.Mock()
manager.get_speak_blank_lines.return_value = False
with mock.patch.object(
web_script_utilities.speech_and_verbosity_manager,
"getManager",
return_value=manager,
):
result = web_script_utilities.Utilities.getPreviousLineContents(utilities, link, 20)
self.assertEqual(result, previousLine)
utilities.previousContext.assert_called_once_with(link, 20, True)
self.assertEqual(
utilities.getLineContentsAtOffset.call_args_list,
[
mock.call(link, 20, None, True),
mock.call(link, 19, layoutMode=False, useCache=False),
],
)
def test_current_line_on_wrapped_link_continuation_does_not_expand_to_parent(self):
link = object()
utilities = self._make_utilities(link)
utilities._currentLineContents = None
utilities._debugContentsInfo = mock.Mock()
utilities.findObjectInContents = mock.Mock(return_value=-1)
utilities.treatAsEndOfLine = mock.Mock(return_value=False)
utilities.getExtents = mock.Mock(return_value=[321, 763, 86, 19])
utilities.isInlineListDescendant = mock.Mock(return_value=False)
utilities._getContentsForObj = mock.Mock(
return_value=[[link, 20, 29, "downloads"]]
)
utilities.getDocumentForObject = mock.Mock()
with (
mock.patch.object(web_script_utilities.AXObject, "is_dead", return_value=False),
mock.patch.object(web_script_utilities.AXObject, "find_ancestor", return_value=None),
mock.patch.object(web_script_utilities.AXUtilities, "is_tool_bar", return_value=False),
mock.patch.object(web_script_utilities.AXUtilities, "is_menu_bar", return_value=False),
):
result = web_script_utilities.Utilities._getLineContentsAtOffset(
utilities,
link,
23,
layoutMode=True,
)
self.assertEqual(result, [[link, 20, 29, "downloads"]])
utilities.getDocumentForObject.assert_not_called()
utilities._debugContentsInfo.assert_called_once_with(
link,
23,
[[link, 20, 29, "downloads"]],
"Line (wrapped link)",
)
def test_next_line_after_split_link_uses_parent_line_after_embedded_marker(self):
link = object()
paragraph = object()
utilities = self._make_utilities(link)
currentLine = [[link, 20, 29, "downloads"]]
paragraphLine = [[paragraph, 95, 191, "[OBJ]. On first boot"]]
expectedLine = [[paragraph, 96, 191, ". On first boot"]]
utilities.nextContext = mock.Mock(return_value=(paragraph, 96))
utilities.getLineContentsAtOffset = mock.Mock(
side_effect=[currentLine, paragraphLine]
)
utilities.getHyperlinkRange = mock.Mock(return_value=(95, 96))
manager = mock.Mock()
manager.get_speak_blank_lines.return_value = False
with (
mock.patch.object(
web_script_utilities.speech_and_verbosity_manager,
"getManager",
return_value=manager,
),
mock.patch.object(
web_script_utilities.AXObject,
"get_parent",
return_value=paragraph,
),
mock.patch.object(
web_script_utilities.AXText,
"get_character_at_offset",
return_value=(utilities.EMBEDDED_OBJECT_CHARACTER, 95, 96),
),
mock.patch.object(
web_script_utilities.AXText,
"get_substring",
return_value=". On first boot",
),
):
result = web_script_utilities.Utilities.getNextLineContents(
utilities,
link,
23,
)
self.assertEqual(result, expectedLine)
utilities.nextContext.assert_called_once_with(link, 28, True)
self.assertEqual(
utilities.getLineContentsAtOffset.call_args_list,
[
mock.call(link, 23, None, True),
mock.call(paragraph, 96, layoutMode=False, useCache=False),
],
)