From b224d699c0de1ba7d90adc9e2d20cf88f5932e15 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 15 May 2026 01:54:17 -0400 Subject: [PATCH] Fixed status announcements interrupting page information for the web navigation. --- src/cthulhu/scripts/web/script.py | 5 +- src/cthulhu/structural_navigation.py | 5 +- ...structural_navigation_table_regressions.py | 40 ++++++++++++ tests/test_web_input_regressions.py | 63 +++++++++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/cthulhu/scripts/web/script.py b/src/cthulhu/scripts/web/script.py index db71882..ccc5071 100644 --- a/src/cthulhu/scripts/web/script.py +++ b/src/cthulhu/scripts/web/script.py @@ -1504,6 +1504,7 @@ class Script(default.Script): def togglePresentationMode(self, inputEvent, documentFrame=None): [obj, characterOffset] = self.utilities.getCaretContext(documentFrame) + interrupt = inputEvent is not None if self._inFocusMode: parent = AXObject.get_parent(obj) if AXUtilities.is_list_box(parent): @@ -1511,7 +1512,7 @@ class Script(default.Script): elif AXUtilities.is_menu(parent): self.utilities.setCaretContext(AXObject.get_parent(parent), -1) if not self._loadingDocumentContent: - self.presentMessage(messages.MODE_BROWSE) + self.presentMessage(messages.MODE_BROWSE, interrupt=interrupt) if not self._shouldSuppressBrowseModeSound(obj, inputEvent): sound_theme_manager.getManager().playBrowseModeSound() else: @@ -1521,7 +1522,7 @@ class Script(default.Script): or inputEvent): self.utilities.grabFocus(obj) - self.presentMessage(messages.MODE_FOCUS) + self.presentMessage(messages.MODE_FOCUS, interrupt=interrupt) sound_theme_manager.getManager().playFocusModeSound() self._inFocusMode = not self._inFocusMode self._focusModeIsSticky = False diff --git a/src/cthulhu/structural_navigation.py b/src/cthulhu/structural_navigation.py index 602d095..67ce85b 100644 --- a/src/cthulhu/structural_navigation.py +++ b/src/cthulhu/structural_navigation.py @@ -935,9 +935,9 @@ class StructuralNavigation: return if not isNext: - self._script.presentMessage(messages.WRAPPING_TO_BOTTOM) + wrapMessage = messages.WRAPPING_TO_BOTTOM else: - self._script.presentMessage(messages.WRAPPING_TO_TOP) + wrapMessage = messages.WRAPPING_TO_TOP matches = self._getAll(structuralNavigationObject, arg) if not isNext: @@ -946,6 +946,7 @@ class StructuralNavigation: for match in matches: if _isValidMatch(match): structuralNavigationObject.present(match, arg) + self._script.presentMessage(wrapMessage, interrupt=False) return structuralNavigationObject.present(None, arg) diff --git a/tests/test_structural_navigation_table_regressions.py b/tests/test_structural_navigation_table_regressions.py index 7a3120f..2487378 100644 --- a/tests/test_structural_navigation_table_regressions.py +++ b/tests/test_structural_navigation_table_regressions.py @@ -42,6 +42,46 @@ class StructuralNavigationTableRegressionTests(unittest.TestCase): interrupt=False, ) + def test_wrapping_announcement_does_not_interrupt_wrapped_object(self): + navigator = structural_navigation.StructuralNavigation.__new__( + structural_navigation.StructuralNavigation + ) + script = mock.Mock() + script.utilities.isZombie.return_value = False + script.utilities.isHidden.return_value = False + script.utilities.isEmpty.return_value = False + script.utilities.pathComparison.return_value = 0 + navigator._script = script + + first = object() + current = object() + structuralNavigationObject = mock.Mock() + structuralNavigationObject.predicate = None + events = [] + structuralNavigationObject.present.side_effect = lambda obj, arg=None: events.append( + ("present", obj, arg) + ) + script.presentMessage.side_effect = lambda message, **kwargs: events.append( + ("message", message, kwargs) + ) + navigator._getAll = mock.Mock(return_value=[first, current]) + + with ( + mock.patch.object(structural_navigation.settings, "wrappedStructuralNavigation", True), + mock.patch.object(structural_navigation.AXObject, "is_dead", return_value=False), + mock.patch.object(structural_navigation.AXObject, "get_parent", return_value=None), + mock.patch.object(structural_navigation.AXObject, "get_path", side_effect=lambda obj: [id(obj)]), + ): + navigator.goObject(structuralNavigationObject, True, current, "arg") + + self.assertEqual( + events, + [ + ("present", first, "arg"), + ("message", messages.WRAPPING_TO_TOP, {"interrupt": False}), + ], + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_web_input_regressions.py b/tests/test_web_input_regressions.py index b17933b..8add719 100644 --- a/tests/test_web_input_regressions.py +++ b/tests/test_web_input_regressions.py @@ -100,6 +100,69 @@ class WebKeyGrabRegressionTests(unittest.TestCase): testScript.refreshKeyGrabs.assert_called_once_with() +class WebPresentationModeSpeechRegressionTests(unittest.TestCase): + def _make_script(self, inFocusMode): + testScript = web_script.Script.__new__(web_script.Script) + testScript._inFocusMode = inFocusMode + testScript._focusModeIsSticky = True + testScript._browseModeIsSticky = True + testScript._loadingDocumentContent = False + testScript._lastCommandWasCaretNav = False + testScript._lastCommandWasStructNav = False + testScript.utilities = mock.Mock() + testScript.utilities.getCaretContext.return_value = ("button", 0) + testScript.utilities.grabFocusWhenSettingCaret.return_value = True + testScript.presentMessage = mock.Mock() + testScript.refreshKeyGrabs = mock.Mock() + testScript._shouldSuppressBrowseModeSound = mock.Mock(return_value=False) + return testScript + + def test_automatic_browse_mode_announcement_does_not_interrupt_focus_presentation(self): + testScript = self._make_script(inFocusMode=True) + soundManager = mock.Mock() + + with ( + mock.patch.object(web_script.AXObject, "get_parent", return_value=None), + mock.patch.object(web_script.AXUtilities, "is_list_box", return_value=False), + mock.patch.object(web_script.AXUtilities, "is_menu", return_value=False), + mock.patch.object(web_script.sound_theme_manager, "getManager", return_value=soundManager), + ): + web_script.Script.togglePresentationMode(testScript, None, "document") + + testScript.presentMessage.assert_called_once_with(messages.MODE_BROWSE, interrupt=False) + soundManager.playBrowseModeSound.assert_called_once_with() + self.assertFalse(testScript._inFocusMode) + self.assertFalse(testScript._focusModeIsSticky) + self.assertFalse(testScript._browseModeIsSticky) + testScript.refreshKeyGrabs.assert_called_once_with() + + def test_automatic_focus_mode_announcement_does_not_interrupt_focus_presentation(self): + testScript = self._make_script(inFocusMode=False) + soundManager = mock.Mock() + + with mock.patch.object( + web_script.sound_theme_manager, + "getManager", + return_value=soundManager, + ): + web_script.Script.togglePresentationMode(testScript, None, "document") + + testScript.presentMessage.assert_called_once_with(messages.MODE_FOCUS, interrupt=False) + soundManager.playFocusModeSound.assert_called_once_with() + self.assertTrue(testScript._inFocusMode) + self.assertFalse(testScript._focusModeIsSticky) + self.assertFalse(testScript._browseModeIsSticky) + testScript.refreshKeyGrabs.assert_called_once_with() + + def test_manual_presentation_mode_toggle_still_interrupts(self): + testScript = self._make_script(inFocusMode=False) + + with mock.patch.object(web_script.sound_theme_manager, "getManager", return_value=mock.Mock()): + web_script.Script.togglePresentationMode(testScript, object(), "document") + + testScript.presentMessage.assert_called_once_with(messages.MODE_FOCUS, interrupt=True) + + if __name__ == "__main__": unittest.main()