# Unit tests for say_all_presenter.py methods. # # Copyright 2025 Igalia, S.L. # Author: Joanmarie Diggs # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the # Free Software Foundation, Inc., Franklin Street, Fifth Floor, # Boston MA 02110-1301 USA. # pylint: disable=wrong-import-position # pylint: disable=import-outside-toplevel # pylint: disable=too-many-public-methods # pylint: disable=too-many-statements # pylint: disable=protected-access # pylint: disable=too-many-arguments # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-locals """Unit tests for say_all_presenter.py methods.""" from __future__ import annotations from typing import TYPE_CHECKING, Any import gi import pytest gi.require_version("Atspi", "2.0") from gi.repository import Atspi if TYPE_CHECKING: from cthulhu_test_context import CthulhuTestContext @pytest.mark.unit class TestSayAllPresenter: """Test SayAllPresenter class methods.""" def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, Any]: """Set up mocks for say_all_presenter dependencies.""" additional_modules = [ "cthulhu.ax_event_synthesizer", "cthulhu.structural_navigator", "cthulhu.input_event", "cthulhu.keybindings", "cthulhu.cmdnames", "cthulhu.guilabels", "cthulhu.ax_object", "cthulhu.ax_text", "cthulhu.ax_utilities", "cthulhu.messages", "cthulhu.input_event_manager", "cthulhu.object_properties", "cthulhu.cthulhu_gui_navlist", "cthulhu.cthulhu_i18n", "cthulhu.AXHypertext", "cthulhu.AXObject", "cthulhu.speech_presenter", "cthulhu.AXTable", "cthulhu.AXText", "cthulhu.AXUtilities", "cthulhu.braille_presenter", "cthulhu.presentation_manager", ] essential_modules = test_context.setup_shared_dependencies(additional_modules) # Set up cmdnames with all required values for structural_navigator cmdnames = essential_modules["cthulhu.cmdnames"] cmdnames.STRUCTURAL_NAVIGATION_MODE_CYCLE = "cycle_mode" cmdnames.BLOCKQUOTE_PREV = "previous_blockquote" cmdnames.BLOCKQUOTE_NEXT = "next_blockquote" cmdnames.BLOCKQUOTE_LIST = "list_blockquotes" cmdnames.BUTTON_PREV = "previous_button" cmdnames.BUTTON_NEXT = "next_button" cmdnames.BUTTON_LIST = "list_buttons" cmdnames.CHECK_BOX_PREV = "previous_checkbox" cmdnames.CHECK_BOX_NEXT = "next_checkbox" cmdnames.CHECK_BOX_LIST = "list_checkboxes" cmdnames.COMBO_BOX_PREV = "previous_combobox" cmdnames.COMBO_BOX_NEXT = "next_combobox" cmdnames.COMBO_BOX_LIST = "list_comboboxes" cmdnames.ENTRY_PREV = "previous_entry" cmdnames.ENTRY_NEXT = "next_entry" cmdnames.ENTRY_LIST = "list_entries" cmdnames.FORM_FIELD_PREV = "previous_form_field" cmdnames.FORM_FIELD_NEXT = "next_form_field" cmdnames.FORM_FIELD_LIST = "list_form_fields" cmdnames.HEADING_PREV = "previous_heading" cmdnames.HEADING_NEXT = "next_heading" cmdnames.HEADING_LIST = "list_headings" cmdnames.HEADING_AT_LEVEL_PREV = "previous_heading_level_%d" cmdnames.HEADING_AT_LEVEL_NEXT = "next_heading_level_%d" cmdnames.HEADING_AT_LEVEL_LIST = "list_headings_level_%d" cmdnames.IFRAME_PREV = "previous_iframe" cmdnames.IFRAME_NEXT = "next_iframe" cmdnames.IFRAME_LIST = "list_iframes" cmdnames.IMAGE_PREV = "previous_image" cmdnames.IMAGE_NEXT = "next_image" cmdnames.IMAGE_LIST = "list_images" cmdnames.LANDMARK_PREV = "previous_landmark" cmdnames.LANDMARK_NEXT = "next_landmark" cmdnames.LANDMARK_LIST = "list_landmarks" cmdnames.LIST_PREV = "previous_list" cmdnames.LIST_NEXT = "next_list" cmdnames.LIST_LIST = "list_lists" cmdnames.LIST_ITEM_PREV = "previous_list_item" cmdnames.LIST_ITEM_NEXT = "next_list_item" cmdnames.LIST_ITEM_LIST = "list_list_items" cmdnames.LIVE_REGION_PREV = "previous_live_region" cmdnames.LIVE_REGION_NEXT = "next_live_region" cmdnames.LIVE_REGION_LAST = "last_live_region" cmdnames.PARAGRAPH_PREV = "previous_paragraph" cmdnames.PARAGRAPH_NEXT = "next_paragraph" cmdnames.PARAGRAPH_LIST = "list_paragraphs" cmdnames.RADIO_BUTTON_PREV = "previous_radio_button" cmdnames.RADIO_BUTTON_NEXT = "next_radio_button" cmdnames.RADIO_BUTTON_LIST = "list_radio_buttons" cmdnames.SEPARATOR_PREV = "previous_separator" cmdnames.SEPARATOR_NEXT = "next_separator" cmdnames.TABLE_PREV = "previous_table" cmdnames.TABLE_NEXT = "next_table" cmdnames.TABLE_LIST = "list_tables" cmdnames.UNVISITED_LINK_PREV = "previous_unvisited_link" cmdnames.UNVISITED_LINK_NEXT = "next_unvisited_link" cmdnames.UNVISITED_LINK_LIST = "list_unvisited_links" cmdnames.VISITED_LINK_PREV = "previous_visited_link" cmdnames.VISITED_LINK_NEXT = "next_visited_link" cmdnames.VISITED_LINK_LIST = "list_visited_links" cmdnames.LINK_PREV = "previous_link" cmdnames.LINK_NEXT = "next_link" cmdnames.LINK_LIST = "list_links" cmdnames.CLICKABLE_PREV = "previous_clickable" cmdnames.CLICKABLE_NEXT = "next_clickable" cmdnames.CLICKABLE_LIST = "list_clickables" cmdnames.LARGE_OBJECT_PREV = "previous_large_object" cmdnames.LARGE_OBJECT_NEXT = "next_large_object" cmdnames.LARGE_OBJECT_LIST = "list_large_objects" cmdnames.CONTAINER_START = "container_start" cmdnames.CONTAINER_END = "container_end" essential_modules["cthulhu.cthulhu_i18n"]._ = lambda x: x essential_modules["cthulhu.debug"].print_message = test_context.Mock() essential_modules["cthulhu.debug"].LEVEL_INFO = 800 controller_mock = test_context.Mock() controller_mock.register_decorated_module.return_value = None essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = controller_mock focus_manager_instance = test_context.Mock() focus_manager_instance.get_locus_of_focus.return_value = None essential_modules["cthulhu.focus_manager"].get_manager.return_value = focus_manager_instance essential_modules["cthulhu.AXObject"].supports_collection.return_value = True essential_modules["cthulhu.AXUtilities"].is_heading.return_value = False from cthulhu import gsettings_registry registry = gsettings_registry.get_registry() registry.clear_runtime_values() return essential_modules def test_say_all_should_skip_content(self, test_context: CthulhuTestContext) -> None: """Test SayAllPresenter._say_all_should_skip_content empty content handling.""" self._setup_dependencies(test_context) from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_obj = test_context.Mock(spec=Atspi.Accessible) content = (mock_obj, 0, 0, "test text") should_skip, reason = presenter._say_all_should_skip_content(content, []) assert should_skip is True assert reason == "start_offset equals end_offset" def test_parse_utterances(self, test_context: CthulhuTestContext) -> None: """Test SayAllPresenter._parse_utterances with various input formats.""" self._setup_dependencies(test_context) from cthulhu import speech from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_acss = test_context.Mock(spec=speech.ACSS) elements, voices = presenter._parse_utterances([]) assert len(elements) == 0 assert len(voices) == 0 elements, voices = presenter._parse_utterances(["Hello world"]) assert len(elements) == 1 assert elements[0] == "Hello world" elements, voices = presenter._parse_utterances([["Hello"], ["world"]]) assert len(elements) == 2 assert elements[0] == "Hello" assert elements[1] == "world" elements, voices = presenter._parse_utterances(["Hello", mock_acss]) assert len(elements) == 1 assert len(voices) == 1 assert elements[0] == "Hello" assert voices[0] == mock_acss def test_get_presenter_singleton(self, test_context: CthulhuTestContext) -> None: """Test that get_presenter returns a singleton instance.""" self._setup_dependencies(test_context) from cthulhu.say_all_presenter import get_presenter presenter1 = get_presenter() presenter2 = get_presenter() assert presenter1 is presenter2 assert presenter1 is not None def test_say_all_presenter_initialization(self, test_context: CthulhuTestContext) -> None: """Test SayAllPresenter initialization sets up required attributes.""" self._setup_dependencies(test_context) from cthulhu import command_manager from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() assert presenter is not None # Verify commands are registered after setup presenter.set_up_commands() cmd_manager = command_manager.get_manager() assert cmd_manager.get_keyboard_command("sayAllHandler") is not None def test_say_all_no_object_scenario(self, test_context: CthulhuTestContext) -> None: """Test SayAllPresenter.say_all with no focus object scenario.""" essential_modules = self._setup_dependencies(test_context) from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_script = test_context.Mock() focus_manager_mock = essential_modules["cthulhu.focus_manager"] manager_instance = test_context.Mock() focus_manager_mock.get_manager.return_value = manager_instance manager_instance.get_locus_of_focus.return_value = None messages_mock = essential_modules["cthulhu.messages"] messages_mock.LOCATION_NOT_FOUND_FULL = "Location not found" pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() pres_manager.present_message.reset_mock() pres_manager.interrupt_presentation.reset_mock() result = presenter.say_all(mock_script, obj=None) assert result is True pres_manager.interrupt_presentation.assert_called_once() pres_manager.present_message.assert_called_once_with("Location not found") @pytest.mark.parametrize( "direction,enabled,contents_available,obj_valid,expected_result", [ pytest.param("rewind", False, True, True, False, id="rewind_disabled"), pytest.param("rewind", True, False, True, True, id="rewind_no_contents_valid_context"), pytest.param( "rewind", True, False, False, False, id="rewind_no_contents_invalid_context_obj", ), pytest.param("rewind", True, True, True, True, id="rewind_success"), pytest.param("fast_forward", False, True, True, False, id="fast_forward_disabled"), pytest.param( "fast_forward", True, False, True, True, id="fast_forward_no_contents_valid_context", ), pytest.param( "fast_forward", True, False, False, False, id="fast_forward_no_contents_invalid_context_obj", ), pytest.param("fast_forward", True, True, True, True, id="fast_forward_success"), ], ) def test_navigation_controls( self, test_context: CthulhuTestContext, direction: str, enabled: bool, contents_available: bool, obj_valid: bool, expected_result: bool, ) -> None: """Test _rewind and _fast_forward navigation controls with various conditions.""" essential_modules = self._setup_dependencies(test_context) from cthulhu import speechserver from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_script = test_context.Mock() presenter._script = mock_script presenter.set_rewind_and_fast_forward_enabled(enabled) mock_context = test_context.Mock(spec=speechserver.SayAllContext) mock_context.obj = "context_obj" if obj_valid else None if direction == "rewind": mock_context.start_offset = 15 else: mock_context.end_offset = 25 if contents_available: if direction == "rewind": presenter._contents = [("content_obj", 5, 10, "text")] else: presenter._contents = [("first_obj", 0, 5, "first"), ("last_obj", 20, 30, "last")] else: presenter._contents = [] focus_manager_mock = essential_modules["cthulhu.focus_manager"] focus_instance = test_context.Mock() focus_manager_mock.get_manager.return_value = focus_instance mock_script.utilities.set_caret_context = test_context.Mock() if direction == "rewind": mock_script.utilities.previous_context.return_value = ("prev_obj", 8) else: mock_script.utilities.next_context.return_value = ("next_obj", 35) presenter.say_all = test_context.Mock(return_value=True) navigation_method = getattr(presenter, f"_{direction}") result = navigation_method(mock_context) assert result == expected_result if expected_result: focus_instance.set_locus_of_focus.assert_called() mock_script.utilities.set_caret_context.assert_called() if direction == "rewind": mock_script.utilities.previous_context.assert_called() else: mock_script.utilities.next_context.assert_called() presenter.say_all.assert_called_once() @pytest.mark.parametrize( "command_method", [ pytest.param("rewind", id="rewind_command"), pytest.param("fast_forward", id="fast_forward_command"), ], ) def test_dbus_navigation_commands( self, test_context: CthulhuTestContext, command_method: str, ) -> None: """Test D-Bus navigation commands delegate to private methods.""" essential_modules = self._setup_dependencies(test_context) from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_script = test_context.Mock() mock_event = test_context.Mock() debug_mock = essential_modules["cthulhu.debug"] debug_mock.LEVEL_INFO = 800 debug_mock.print_tokens = test_context.Mock() private_method_name = f"_{command_method}" test_context.patch_object(presenter, private_method_name, return_value=True) command = getattr(presenter, command_method) result = command(mock_script, mock_event, notify_user=True) assert result is True debug_mock.print_tokens.assert_called_once() private_method = getattr(presenter, private_method_name) private_method.assert_called_once_with(None, True) @pytest.mark.parametrize( "case", [ { "id": "rewind_setting_disabled_no_override", "direction": "rewind", "override_setting": False, "setting_enabled": False, "context_provided": False, "expected_result": False, }, { "id": "rewind_setting_disabled_with_override", "direction": "rewind", "override_setting": True, "setting_enabled": False, "context_provided": False, "expected_result": True, }, { "id": "rewind_setting_enabled_no_override", "direction": "rewind", "override_setting": False, "setting_enabled": True, "context_provided": False, "expected_result": True, }, { "id": "rewind_setting_enabled_with_override", "direction": "rewind", "override_setting": True, "setting_enabled": True, "context_provided": False, "expected_result": True, }, { "id": "rewind_with_provided_context", "direction": "rewind", "override_setting": False, "setting_enabled": True, "context_provided": True, "expected_result": True, }, { "id": "fast_forward_setting_disabled_no_override", "direction": "fast_forward", "override_setting": False, "setting_enabled": False, "context_provided": False, "expected_result": False, }, { "id": "fast_forward_setting_disabled_with_override", "direction": "fast_forward", "override_setting": True, "setting_enabled": False, "context_provided": False, "expected_result": True, }, { "id": "fast_forward_setting_enabled_no_override", "direction": "fast_forward", "override_setting": False, "setting_enabled": True, "context_provided": False, "expected_result": True, }, { "id": "fast_forward_setting_enabled_with_override", "direction": "fast_forward", "override_setting": True, "setting_enabled": True, "context_provided": False, "expected_result": True, }, { "id": "fast_forward_with_provided_context", "direction": "fast_forward", "override_setting": False, "setting_enabled": True, "context_provided": True, "expected_result": True, }, ], ids=lambda case: case["id"], ) def test_navigation_methods_with_parameters( self, test_context: CthulhuTestContext, case: dict, ) -> None: """Test _rewind and _fast_forward methods with override_setting parameter.""" direction = case["direction"] override_setting = case["override_setting"] setting_enabled = case["setting_enabled"] context_provided = case["context_provided"] expected_result = case["expected_result"] essential_modules = self._setup_dependencies(test_context) from cthulhu import speechserver from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_script = test_context.Mock() presenter._script = mock_script presenter.set_rewind_and_fast_forward_enabled(setting_enabled) mock_context = ( test_context.Mock(spec=speechserver.SayAllContext) if context_provided else None ) if context_provided and mock_context is not None: mock_context.obj = "provided_obj" if direction == "rewind": mock_context.start_offset = 10 else: mock_context.end_offset = 20 current_context = test_context.Mock(spec=speechserver.SayAllContext) current_context.obj = "current_obj" if direction == "rewind": current_context.start_offset = 5 else: current_context.end_offset = 15 presenter._current_context = current_context if direction == "rewind": presenter._contents = [("content_obj", 0, 10, "text")] else: presenter._contents = [("first_obj", 0, 5, "first"), ("last_obj", 10, 20, "last")] focus_manager_mock = essential_modules["cthulhu.focus_manager"] focus_instance = test_context.Mock() focus_manager_mock.get_manager.return_value = focus_instance mock_script.utilities.set_caret_context = test_context.Mock() if direction == "rewind": mock_script.utilities.previous_context.return_value = ("prev_obj", 3) else: mock_script.utilities.next_context.return_value = ("next_obj", 25) presenter.say_all = test_context.Mock(return_value=True) navigation_method = getattr(presenter, f"_{direction}") result = navigation_method(mock_context, override_setting) assert result == expected_result if expected_result: focus_instance.set_locus_of_focus.assert_called() mock_script.utilities.set_caret_context.assert_called() presenter.say_all.assert_called_once() def test_say_all_initialization_clears_state(self, test_context: CthulhuTestContext) -> None: """Test say_all method clears contexts, contents, and current_context at start.""" essential_modules = self._setup_dependencies(test_context) from cthulhu import speechserver from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_script = test_context.Mock() presenter._contexts = [test_context.Mock(spec=speechserver.SayAllContext)] presenter._contents = [("old_obj", 0, 5, "old")] presenter._current_context = test_context.Mock(spec=speechserver.SayAllContext) focus_manager_mock = essential_modules["cthulhu.focus_manager"] manager_instance = test_context.Mock() focus_manager_mock.get_manager.return_value = manager_instance manager_instance.get_locus_of_focus.return_value = "focus_obj" debug_mock = essential_modules["cthulhu.debug"] debug_mock.LEVEL_INFO = 800 debug_mock.print_tokens = test_context.Mock() from cthulhu import speech test_context.patch_object(speech, "say_all", return_value=None) mock_script.utilities.get_caret_context.return_value = ("obj", 10) result = presenter.say_all(mock_script, None) assert result is True assert not presenter._contexts assert not presenter._contents assert presenter._current_context is None def test_progress_callback_sets_current_context(self, test_context: CthulhuTestContext) -> None: """Test that _progress_callback sets the current context.""" self._setup_dependencies(test_context) from cthulhu import speechserver from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_context = test_context.Mock(spec=speechserver.SayAllContext) assert presenter._current_context is None presenter._current_context = mock_context assert presenter._current_context is mock_context def test_say_all_is_running_initialized_to_false(self, test_context: CthulhuTestContext) -> None: """Test that _say_all_is_running is initialized to False.""" self._setup_dependencies(test_context) from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() assert presenter._say_all_is_running is False def test_say_all_clears_say_all_is_running(self, test_context: CthulhuTestContext) -> None: """Test that say_all resets _say_all_is_running to False.""" essential_modules = self._setup_dependencies(test_context) from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_script = test_context.Mock() presenter._say_all_is_running = True focus_manager_mock = essential_modules["cthulhu.focus_manager"] manager_instance = test_context.Mock() focus_manager_mock.get_manager.return_value = manager_instance manager_instance.get_locus_of_focus.return_value = None messages_mock = essential_modules["cthulhu.messages"] messages_mock.LOCATION_NOT_FOUND_FULL = "Location not found" presenter.say_all(mock_script) assert presenter._say_all_is_running is False def test_progress_callback_sets_say_all_is_running_true( self, test_context: CthulhuTestContext, ) -> None: """Test that _progress_callback sets _say_all_is_running to True.""" essential_modules = self._setup_dependencies(test_context) from cthulhu import speechserver from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_script = test_context.Mock() presenter._script = mock_script mock_context = test_context.Mock(spec=speechserver.SayAllContext) mock_context.obj = test_context.Mock() mock_context.current_offset = 5 mock_context.current_end_offset = 10 from cthulhu.ax_utilities import AXUtilities test_context.patch_object(AXUtilities, "character_at_offset_is_eoc", return_value=False) focus_manager_mock = essential_modules["cthulhu.focus_manager"] focus_instance = test_context.Mock() focus_manager_mock.get_manager.return_value = focus_instance focus_manager_mock.SAY_ALL = "say-all" assert presenter._say_all_is_running is False presenter._progress_callback(mock_context, speechserver.SayAllContext.PROGRESS) assert presenter._say_all_is_running is True def test_progress_callback_uses_say_all_mode_when_running( self, test_context: CthulhuTestContext, ) -> None: """Test that _progress_callback uses SAY_ALL mode when _say_all_is_running is True.""" essential_modules = self._setup_dependencies(test_context) from cthulhu import speechserver from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_script = test_context.Mock() presenter._script = mock_script mock_context = test_context.Mock(spec=speechserver.SayAllContext) mock_context.obj = test_context.Mock() mock_context.current_offset = 5 mock_context.current_end_offset = 10 from cthulhu.ax_utilities import AXUtilities test_context.patch_object(AXUtilities, "character_at_offset_is_eoc", return_value=False) focus_manager_mock = essential_modules["cthulhu.focus_manager"] focus_instance = test_context.Mock() focus_manager_mock.get_manager.return_value = focus_instance focus_manager_mock.SAY_ALL = "say-all" presenter._progress_callback(mock_context, speechserver.SayAllContext.PROGRESS) focus_instance.emit_region_changed.assert_called_once_with( mock_context.obj, mock_context.current_offset, mock_context.current_end_offset, "say-all", ) def test_progress_callback_uses_focus_tracking_mode_when_interrupted( self, test_context: CthulhuTestContext, ) -> None: """Test that _progress_callback uses FOCUS_TRACKING mode when interrupted by keyboard.""" self._setup_dependencies(test_context) from cthulhu import focus_manager as fm from cthulhu import input_event_manager, speechserver from cthulhu.ax_text import AXText from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_script = test_context.Mock() presenter._script = mock_script mock_context = test_context.Mock(spec=speechserver.SayAllContext) mock_context.obj = test_context.Mock() mock_context.current_offset = 5 mock_context.current_end_offset = 10 from cthulhu.ax_utilities import AXUtilities test_context.patch_object(AXUtilities, "character_at_offset_is_eoc", return_value=False) test_context.patch_object(AXText, "set_caret_offset", return_value=True) focus_instance = test_context.Mock() test_context.patch_object(fm, "get_manager", return_value=focus_instance) iem_instance = test_context.Mock() iem_instance.last_event_was_keyboard.return_value = True iem_instance.last_event_was_down.return_value = False iem_instance.last_event_was_up.return_value = False test_context.patch_object(input_event_manager, "get_manager", return_value=iem_instance) presenter._progress_callback(mock_context, speechserver.SayAllContext.INTERRUPTED) assert presenter._say_all_is_running is False focus_instance.emit_region_changed.assert_called_once_with( mock_context.obj, mock_context.current_offset, None, fm.FOCUS_TRACKING, ) @pytest.mark.parametrize( "end_offset, expected_next_context_offset", [ pytest.param(16, 15, id="normal_offset_passes_end_minus_one"), pytest.param(1, 0, id="small_offset_passes_end_minus_one"), pytest.param(0, 0, id="zero_offset_passes_zero_not_negative"), pytest.param(100, 99, id="large_offset_passes_end_minus_one"), ], ) def test_say_all_iter_next_context_uses_end_offset_minus_one( self, test_context: CthulhuTestContext, end_offset: int, expected_next_context_offset: int, ) -> None: """Test that _say_all_iter passes end_offset - 1 to next_context. The end offset from sentence contents is exclusive (position end is NOT part of the content). find_next_caret_in_order looks at offset + 1, so passing end directly would skip position end. We must pass end - 1 so that find_next_caret_in_order looks at position end, which is where embedded object characters (FFFC) representing child elements may be. """ essential_modules = self._setup_dependencies(test_context) from cthulhu import gsettings_registry from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() mock_script = test_context.Mock() mock_obj = test_context.Mock(spec=Atspi.Accessible) # Set up the presenter's script presenter._script = mock_script # Set up settings for sentence-by-sentence say all gsettings_registry.get_registry().set_runtime_value("say-all", "style", "sentence") # Mock utilities - return contents once, then return empty to exit loop call_count = [0] def mock_get_sentence_contents(_obj, _offset): call_count[0] += 1 if call_count[0] == 1: return [(mock_obj, 0, end_offset, "Test sentence.")] return [] mock_script.utilities.get_sentence_contents_at_offset.side_effect = ( mock_get_sentence_contents ) mock_script.utilities.filter_contents_for_presentation.side_effect = lambda x: x # next_context returns None to end the loop mock_script.utilities.next_context.return_value = (None, -1) # Mock speech presenter to return something so the content is processed speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() speech_pres.generate_speech_contents.return_value = [["Test"], []] # Mock AXUtilities ax_utilities_mock = essential_modules["cthulhu.ax_utilities"] ax_utilities_mock.is_text.return_value = False ax_utilities_mock.is_terminal.return_value = False # Mock _say_all_should_skip_content to avoid dependency issues test_context.patch_object( presenter, "_say_all_should_skip_content", return_value=(False, ""), ) # Mock debug debug_mock = essential_modules["cthulhu.debug"] debug_mock.LEVEL_INFO = 800 debug_mock.print_tokens = test_context.Mock() debug_mock.print_message = test_context.Mock() # Mock focus_manager for set_locus_of_focus focus_manager_mock = essential_modules["cthulhu.focus_manager"] manager_instance = test_context.Mock() focus_manager_mock.get_manager.return_value = manager_instance # Mock event_synthesizer essential_modules[ "cthulhu.ax_event_synthesizer" ].get_synthesizer.return_value.scroll_into_view = test_context.Mock() # Mock utilities.set_caret_offset mock_script.utilities.set_caret_offset = test_context.Mock() # Consume the generator to trigger next_context call generator = presenter._say_all_iter(mock_obj, 0) for _ in generator: pass # Verify next_context was called with end_offset - 1 (or 0 if that would be negative) mock_script.utilities.next_context.assert_called_once() call_args = mock_script.utilities.next_context.call_args # next_context is called with positional args: (last_obj, offset, restrict_to=...) actual_offset = call_args[0][1] assert actual_offset == expected_next_context_offset, ( f"Expected next_context to be called with offset {expected_next_context_offset}, " f"but was called with {actual_offset}" ) def test_stop_clears_all_state(self, test_context: CthulhuTestContext) -> None: """Test SayAllPresenter.stop clears contexts, contents, current_context and running flag.""" essential_modules = self._setup_dependencies(test_context) from cthulhu import speechserver from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() presenter._contexts = [test_context.Mock(spec=speechserver.SayAllContext)] presenter._contents = [("obj", 0, 5, "text")] presenter._current_context = test_context.Mock(spec=speechserver.SayAllContext) presenter._say_all_is_running = True focus_manager_mock = essential_modules["cthulhu.focus_manager"] manager_instance = test_context.Mock() focus_manager_mock.get_manager.return_value = manager_instance presenter.stop() assert not presenter._contexts assert not presenter._contents assert presenter._current_context is None assert presenter._say_all_is_running is False manager_instance.reset_active_mode.assert_called_once_with( "SAY ALL PRESENTER: Stopped Say All.", ) def test_stop_from_empty_state(self, test_context: CthulhuTestContext) -> None: """Test SayAllPresenter.stop works correctly when already in empty state.""" essential_modules = self._setup_dependencies(test_context) from cthulhu.say_all_presenter import SayAllPresenter presenter = SayAllPresenter() assert not presenter._contexts assert not presenter._contents assert presenter._current_context is None assert presenter._say_all_is_running is False focus_manager_mock = essential_modules["cthulhu.focus_manager"] manager_instance = test_context.Mock() focus_manager_mock.get_manager.return_value = manager_instance presenter.stop() assert not presenter._contexts assert not presenter._contents assert presenter._current_context is None assert presenter._say_all_is_running is False manager_instance.reset_active_mode.assert_called_once()