# Unit tests for ax_text.py methods.
#
# Copyright 2025 Igalia, S.L.
# Author: Joanmarie Diggs <jdiggs@igalia.com>
#
# 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=too-many-public-methods
# pylint: disable=wrong-import-position
# pylint: disable=protected-access
# pylint: disable=too-many-arguments
# pylint: disable=too-many-positional-arguments
# pylint: disable=import-outside-toplevel
# pylint: disable=too-many-lines
# pylint: disable=too-many-locals

"""Unit tests for ax_text.py methods."""

from __future__ import annotations

from typing import TYPE_CHECKING

import gi
import pytest

gi.require_version("Atspi", "2.0")
from gi.repository import Atspi, GLib

if TYPE_CHECKING:
    from collections.abc import Generator
    from unittest.mock import MagicMock

    from .orca_test_context import OrcaTestContext


class MockRect:
    """Mock rectangle class for testing."""

    def __init__(self, x: int = 0, y: int = 0, width: int = 0, height: int = 0) -> None:
        self.x = x
        self.y = y
        self.width = width
        self.height = height


@pytest.mark.unit
class TestAXTextAttribute:
    """Test AXTextAttribute enum methods."""

    def _setup_dependencies(self, test_context: OrcaTestContext) -> dict[str, MagicMock]:
        """Set up mocks for ax_text dependencies."""

        additional_modules = [
            "locale",
            "orca.colornames",
            "orca.ax_utilities_role",
            "orca.ax_utilities_state",
        ]
        essential_modules = test_context.setup_shared_dependencies(additional_modules)

        locale_mock = essential_modules["locale"]
        locale_mock.localeconv = test_context.Mock(return_value={"decimal_point": "."})

        colornames_mock = essential_modules["orca.colornames"]
        colornames_mock.COLOR_NAMES = {"#ff0000": "red", "#00ff00": "green"}
        colornames_mock.rgb_string_to_color_name = test_context.Mock(
            side_effect=lambda color: colornames_mock.COLOR_NAMES.get(color, color),
        )
        colornames_mock.normalize_rgb_string = test_context.Mock(side_effect=lambda color: color)
        colornames_mock.get_presentable_color_name = test_context.Mock(
            side_effect=lambda color: colornames_mock.COLOR_NAMES.get(color, color),
        )

        debug_mock = essential_modules["orca.debug"]
        debug_mock.LEVEL_INFO = 800
        debug_mock.debugLevel = 0
        debug_mock.debug = test_context.Mock()
        debug_mock.print_message = test_context.Mock()

        messages_mock = essential_modules["orca.messages"]
        messages_mock.TEXT_ATTRIBUTE_NAMES = {
            "bg-color": "Background Color",
            "size": "size",
            "language": "Language",
            "weight": "Weight",
            "family-name": "Family Name",
        }
        messages_mock.pixel_count = test_context.Mock(
            side_effect=lambda count: f"{count} pixel{'s' if count != 1.0 else ''}",
        )

        text_attribute_names_mock = essential_modules["orca.text_attribute_names"]
        text_attribute_names_mock.attribute_names = {
            "bg-color": "Background Color",
            "size": "size",
            "language": "Language",
            "weight": "Weight",
            "family-name": "Family Name",
        }
        text_attribute_names_mock.attribute_values = test_context.Mock()
        text_attribute_names_mock.attribute_values.get = test_context.Mock(
            side_effect=lambda value, default: default,
        )

        ax_object_mock = essential_modules["orca.ax_object"]
        ax_object_class_mock = test_context.Mock()
        ax_object_class_mock.supports_text = test_context.Mock(return_value=True)
        ax_object_mock.AXObject = ax_object_class_mock

        ax_utilities_role_mock = essential_modules["orca.ax_utilities_role"]
        role_class_mock = test_context.Mock()
        ax_utilities_role_mock.AXUtilitiesRole = role_class_mock

        ax_utilities_state_mock = essential_modules["orca.ax_utilities_state"]
        state_class_mock = test_context.Mock()
        ax_utilities_state_mock.AXUtilitiesState = state_class_mock

        return essential_modules

    def test_from_string_with_valid_attribute(self, test_context: OrcaTestContext) -> None:
        """Test AXTextAttribute.from_string with valid attribute name."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXTextAttribute

        result = AXTextAttribute.from_string("bg-color")
        assert result == AXTextAttribute.BG_COLOR

    def test_from_string_with_invalid_attribute(self, test_context: OrcaTestContext) -> None:
        """Test AXTextAttribute.from_string with invalid attribute name."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXTextAttribute

        result = AXTextAttribute.from_string("invalid-attribute")
        assert result is None

    def test_from_localized_string_with_valid_attribute(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXTextAttribute.from_localized_string with valid localized name."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXTextAttribute

        result = AXTextAttribute.from_localized_string("Background Color")
        assert result == AXTextAttribute.BG_COLOR

    def test_from_localized_string_with_invalid_attribute(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXTextAttribute.from_localized_string with invalid localized name."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXTextAttribute

        result = AXTextAttribute.from_localized_string("Invalid Localized")
        assert result is None

    def test_get_attribute_name(self, test_context: OrcaTestContext) -> None:
        """Test AXTextAttribute.get_attribute_name."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXTextAttribute

        result = AXTextAttribute.BG_COLOR.get_attribute_name()
        assert result == "bg-color"

    def test_get_localized_name_with_translation(self, test_context: OrcaTestContext) -> None:
        """Test AXTextAttribute.get_localized_name with available translation."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXTextAttribute

        result = AXTextAttribute.BG_COLOR.get_localized_name()
        assert result == "Background Color"

    def test_get_localized_name_without_translation(self, test_context: OrcaTestContext) -> None:
        """Test AXTextAttribute.get_localized_name without available translation."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXTextAttribute

        result = AXTextAttribute.INDENT.get_localized_name()
        assert result == "indent"

    @pytest.mark.parametrize(
        "case",
        [
            {
                "id": "pixel_int_multiple",
                "attribute_name": "INDENT",
                "input_value": "12px",
                "expected_result": "12 pixels",
            },
            {
                "id": "pixel_int_singular",
                "attribute_name": "INDENT",
                "input_value": "1px",
                "expected_result": "1 pixel",
            },
            {
                "id": "pixel_int_zero",
                "attribute_name": "INDENT",
                "input_value": "0px",
                "expected_result": "0 pixels",
            },
            {
                "id": "pixel_float_multiple",
                "attribute_name": "INDENT",
                "input_value": "12.5px",
                "expected_result": "12.5 pixels",
            },
            {
                "id": "pixel_float_singular",
                "attribute_name": "INDENT",
                "input_value": "1.0px",
                "expected_result": "1.0 pixel",
            },
            {
                "id": "pixel_float_zero",
                "attribute_name": "INDENT",
                "input_value": "0.0px",
                "expected_result": "0.0 pixels",
            },
            {
                "id": "color_red",
                "attribute_name": "BG_COLOR",
                "input_value": "#ff0000",
                "expected_result": "red",
            },
            {
                "id": "moz_prefix_removal",
                "attribute_name": "JUSTIFICATION",
                "input_value": "left-moz",
                "expected_result": "left",
            },
            {
                "id": "justify_to_fill",
                "attribute_name": "JUSTIFICATION",
                "input_value": "justify",
                "expected_result": "fill",
            },
            {
                "id": "family_name_cleanup",
                "attribute_name": "FAMILY_NAME",
                "input_value": "Arial, sans-serif",
                "expected_result": "Arial",
            },
            {
                "id": "regular_value",
                "attribute_name": "LANGUAGE",
                "input_value": "en-US",
                "expected_result": "en-US",
            },
            {
                "id": "none_value",
                "attribute_name": "LANGUAGE",
                "input_value": None,
                "expected_result": "",
            },
        ],
        ids=lambda case: case["id"],
    )
    def test_get_localized_value_scenarios(
        self,
        test_context: OrcaTestContext,
        case: dict,
    ) -> None:
        """Test AXTextAttribute.get_localized_value with various scenarios."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXTextAttribute

        attribute = getattr(AXTextAttribute, case["attribute_name"])
        result = attribute.get_localized_value(case["input_value"])
        assert result == case["expected_result"]

    @pytest.mark.parametrize(
        "case",
        [
            {"id": "bg_color_true", "attribute": "BG_COLOR", "expected": True},
            {"id": "bg_full_height_false", "attribute": "BG_FULL_HEIGHT", "expected": False},
        ],
        ids=lambda case: case["id"],
    )
    def test_should_present_by_default(self, test_context: OrcaTestContext, case: dict) -> None:
        """Test AXTextAttribute.should_present_by_default."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXTextAttribute

        attr = getattr(AXTextAttribute, case["attribute"])
        result = attr.should_present_by_default()
        assert result == case["expected"]

    @pytest.mark.parametrize(
        "case",
        [
            {"id": "bg_color_zero", "attribute": "BG_COLOR", "value": "0", "expected": True},
            {"id": "bg_color_zero_px", "attribute": "BG_COLOR", "value": "0px", "expected": True},
            {"id": "bg_color_none", "attribute": "BG_COLOR", "value": "none", "expected": True},
            {"id": "bg_color_empty", "attribute": "BG_COLOR", "value": "", "expected": True},
            {"id": "bg_color_null", "attribute": "BG_COLOR", "value": None, "expected": True},
            {"id": "scale_one", "attribute": "SCALE", "value": "1.0", "expected": True},
            {
                "id": "text_position_baseline",
                "attribute": "TEXT_POSITION",
                "value": "baseline",
                "expected": True,
            },
            {"id": "weight_400", "attribute": "WEIGHT", "value": "400", "expected": True},
            {
                "id": "bg_color_bold_false",
                "attribute": "BG_COLOR",
                "value": "bold",
                "expected": False,
            },
        ],
        ids=lambda case: case["id"],
    )
    def test_value_is_default(self, test_context: OrcaTestContext, case: dict) -> None:
        """Test AXTextAttribute.value_is_default."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXTextAttribute

        attr = getattr(AXTextAttribute, case["attribute"])
        result = attr.value_is_default(case["value"])
        assert result == case["expected"]


@pytest.mark.unit
class TestAXText:
    """Test AXText class methods."""

    def _setup_dependencies(self, test_context: OrcaTestContext) -> dict[str, MagicMock]:
        """Set up mocks for ax_text dependencies."""

        additional_modules = [
            "locale",
            "orca.colornames",
            "orca.ax_utilities_role",
            "orca.ax_utilities_state",
        ]
        essential_modules = test_context.setup_shared_dependencies(additional_modules)

        locale_mock = essential_modules["locale"]
        locale_mock.localeconv = test_context.Mock(return_value={"decimal_point": "."})

        colornames_mock = essential_modules["orca.colornames"]
        colornames_mock.COLOR_NAMES = {"#ff0000": "red", "#00ff00": "green"}
        colornames_mock.rgb_string_to_color_name = test_context.Mock(
            side_effect=lambda color: colornames_mock.COLOR_NAMES.get(color, color),
        )
        colornames_mock.normalize_rgb_string = test_context.Mock(side_effect=lambda color: color)
        colornames_mock.get_presentable_color_name = test_context.Mock(
            side_effect=lambda color: colornames_mock.COLOR_NAMES.get(color, color),
        )

        debug_mock = essential_modules["orca.debug"]
        debug_mock.LEVEL_INFO = 800
        debug_mock.debugLevel = 0
        debug_mock.debug = test_context.Mock()
        debug_mock.print_message = test_context.Mock()

        messages_mock = essential_modules["orca.messages"]
        messages_mock.TEXT_ATTRIBUTE_NAMES = {
            "bg-color": "Background Color",
            "size": "size",
            "language": "Language",
            "weight": "Weight",
            "family-name": "Family Name",
        }
        messages_mock.pixel_count = test_context.Mock(
            side_effect=lambda count: f"{count} pixel{'s' if count != 1.0 else ''}",
        )

        text_attribute_names_mock = essential_modules["orca.text_attribute_names"]
        text_attribute_names_mock.attribute_names = {
            "bg-color": "Background Color",
            "size": "size",
            "language": "Language",
            "weight": "Weight",
            "family-name": "Family Name",
        }
        text_attribute_names_mock.attribute_values = test_context.Mock()
        text_attribute_names_mock.attribute_values.get = test_context.Mock(
            side_effect=lambda value, default: default,
        )

        ax_object_mock = essential_modules["orca.ax_object"]
        ax_object_class_mock = test_context.Mock()
        ax_object_class_mock.supports_text = test_context.Mock(return_value=True)
        ax_object_mock.AXObject = ax_object_class_mock

        ax_utilities_role_mock = essential_modules["orca.ax_utilities_role"]
        role_class_mock = test_context.Mock()
        ax_utilities_role_mock.AXUtilitiesRole = role_class_mock

        ax_utilities_state_mock = essential_modules["orca.ax_utilities_state"]
        state_class_mock = test_context.Mock()
        ax_utilities_state_mock.AXUtilitiesState = state_class_mock

        return essential_modules

    def test_is_eoc_with_embedded_object_character(self, test_context: OrcaTestContext) -> None:
        """Test AXText.is_eoc with embedded object character."""

        self._setup_dependencies(test_context)
        from orca.ax_utilities_text import AXUtilitiesText

        result = AXUtilitiesText.is_eoc("\ufffc")
        assert result is True

    def test_import_registers_text_cache_namespaces(self, test_context: OrcaTestContext) -> None:
        """Test importing text utilities registers its cache namespaces."""

        self._setup_dependencies(test_context)
        from orca import ax_cache_manager

        manager = ax_cache_manager.get_manager()
        register = test_context.patch_object(
            manager,
            "register_cache",
            wraps=manager.register_cache,
        )

        from orca.ax_utilities_text import AXUtilitiesText

        expected_namespaces = {
            AXUtilitiesText._CACHE.TEXT_ATTRIBUTES,
            AXUtilitiesText._CACHE.SELECTED_TEXT,
            AXUtilitiesText._CACHE.LAST_TEXT_UNIT_SPOKEN,
        }
        registered_namespaces = {
            call.args[1]
            for call in register.call_args_list
            if call.args[0] is AXUtilitiesText._CACHE
        }

        assert registered_namespaces == expected_namespaces
        for call in register.call_args_list:
            if call.args[0] is AXUtilitiesText._CACHE:
                assert call.kwargs["lifetime"] is ax_cache_manager.Lifetime.PROCESS
                assert call.kwargs["clear_on_demand"] is ax_cache_manager.ClearPolicy.PRESERVE
                assert call.kwargs["clear_interval_seconds"] is None

    def test_manager_clear_cache_now_preserves_text_cache(
        self, test_context: OrcaTestContext
    ) -> None:
        """Test routine manager clearing preserves text utility cached values."""

        self._setup_dependencies(test_context)
        from orca import ax_cache_manager
        from orca.ax_utilities_text import AXUtilitiesText, TextUnit

        mock_accessible = test_context.Mock(spec=Atspi.Accessible)
        AXUtilitiesText._CACHE.set_text_attributes({"weight": "bold"})
        AXUtilitiesText._CACHE.set_selected_text(mock_accessible, ("selected", 1, 9))
        AXUtilitiesText.set_last_text_unit_spoken(TextUnit.WORD)

        ax_cache_manager.get_manager().clear_cache_now("test reason")

        assert AXUtilitiesText.get_cached_text_attributes() == {"weight": "bold"}
        assert AXUtilitiesText.get_cached_selected_text(mock_accessible) == ("selected", 1, 9)
        assert AXUtilitiesText.get_last_text_unit_spoken() is TextUnit.WORD

    def test_is_eoc_with_regular_character(self, test_context: OrcaTestContext) -> None:
        """Test AXText.is_eoc with regular character."""

        self._setup_dependencies(test_context)
        from orca.ax_utilities_text import AXUtilitiesText

        result = AXUtilitiesText.is_eoc("a")
        assert result is False

    def test_character_at_offset_is_eoc_with_eoc(self, test_context: OrcaTestContext) -> None:
        """Test AXText.character_at_offset_is_eoc with embedded object character."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_character_at_offset", return_value=("\ufffc", 5, 6))
        result = AXUtilitiesText.character_at_offset_is_eoc(
            test_context.Mock(spec=Atspi.Accessible), 5
        )
        assert result is True

    def test_character_at_offset_is_eoc_without_eoc(self, test_context: OrcaTestContext) -> None:
        """Test AXText.character_at_offset_is_eoc without embedded object character."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_character_at_offset", return_value=("a", 5, 6))
        result = AXUtilitiesText.character_at_offset_is_eoc(
            test_context.Mock(spec=Atspi.Accessible), 5
        )
        assert result is False

    def test_is_whitespace_or_empty_with_whitespace(self, test_context: OrcaTestContext) -> None:
        """Test AXText.is_whitespace_or_empty with whitespace text."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_all_text", return_value="   \t\n  ")
        result = AXUtilitiesText.is_whitespace_or_empty(test_context.Mock(spec=Atspi.Accessible))
        assert result is True

    def test_is_whitespace_or_empty_with_content(self, test_context: OrcaTestContext) -> None:
        """Test AXText.is_whitespace_or_empty with actual content."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_all_text", return_value="Hello world")
        result = AXUtilitiesText.is_whitespace_or_empty(test_context.Mock(spec=Atspi.Accessible))
        assert result is False

    def test_has_presentable_text_with_word_characters(self, test_context: OrcaTestContext) -> None:
        """Test AXText.has_presentable_text with word characters."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_all_text", return_value="Hello123")
        result = AXUtilitiesText.has_presentable_text(test_context.Mock(spec=Atspi.Accessible))
        assert result is True

    def test_has_presentable_text_without_word_characters(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.has_presentable_text without word characters."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_all_text", return_value="!@#$%^&*()")
        result = AXUtilitiesText.has_presentable_text(test_context.Mock(spec=Atspi.Accessible))
        assert result is False

    @pytest.mark.parametrize(
        "case",
        [
            {
                "id": "successful",
                "should_raise_error": False,
                "expected_result": 50,
                "expected_debug_method": "print_tokens",
            },
            {
                "id": "glib_error",
                "should_raise_error": True,
                "expected_result": -1,
                "expected_debug_method": "print_message",
            },
        ],
        ids=lambda case: case["id"],
    )
    def test_get_caret_offset(
        self,
        test_context: OrcaTestContext,
        case: dict,
    ) -> None:
        """Test AXText.get_caret_offset successful case and GLib.GError handling."""

        essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        if case["should_raise_error"]:

            def raise_glib_error(_obj) -> None:
                raise GLib.GError("Test error")

            test_context.patch_object(Atspi.Text, "get_caret_offset", new=raise_glib_error)
        else:
            test_context.patch_object(Atspi.Text, "get_caret_offset", return_value=50)

        essential_modules["orca.debug"].print_tokens = test_context.Mock()
        essential_modules["orca.debug"].print_message = test_context.Mock()
        result = AXText.get_caret_offset(test_context.Mock(spec=Atspi.Accessible))
        assert result == case["expected_result"]
        getattr(essential_modules["orca.debug"], case["expected_debug_method"]).assert_called()

    def test_set_caret_offset_successful(self, test_context: OrcaTestContext) -> None:
        """Test AXText.set_caret_offset successful case."""

        essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(Atspi.Text, "set_caret_offset", return_value=True)
        essential_modules["orca.debug"].print_tokens = test_context.Mock()
        result = AXText.set_caret_offset(test_context.Mock(spec=Atspi.Accessible), 25)
        assert result is True
        essential_modules["orca.debug"].print_tokens.assert_called()

    def test_set_caret_offset_with_glib_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.set_caret_offset handles GLib.GError."""

        essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        def raise_glib_error(_obj, _offset) -> None:
            raise GLib.GError("Test error")

        test_context.patch_object(Atspi.Text, "set_caret_offset", new=raise_glib_error)
        essential_modules["orca.debug"].print_message = test_context.Mock()
        result = AXText.set_caret_offset(test_context.Mock(spec=Atspi.Accessible), 25)
        assert result is False
        essential_modules["orca.debug"].print_message.assert_called()

    def test_set_caret_offset_to_start(self, test_context: OrcaTestContext) -> None:
        """Test AXText.set_caret_offset_to_start."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText, CaretSetReason

        test_context.patch_object(AXText, "set_caret_offset", return_value=True)
        result = AXUtilitiesText.set_caret_offset_to_start(
            test_context.Mock(spec=Atspi.Accessible), CaretSetReason.CARET_NAVIGATION
        )
        assert result is True

    def test_set_caret_offset_to_end(self, test_context: OrcaTestContext) -> None:
        """Test AXText.set_caret_offset_to_end."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText, CaretSetReason

        test_context.patch_object(AXText, "get_character_count", return_value=100)
        test_context.patch_object(AXText, "set_caret_offset", return_value=True)
        result = AXUtilitiesText.set_caret_offset_to_end(
            test_context.Mock(spec=Atspi.Accessible), CaretSetReason.CARET_NAVIGATION
        )
        assert result is True

    def test_has_selected_text_without_selection(self, test_context: OrcaTestContext) -> None:
        """Test AXText.has_selected_text without text selection."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_n_selections", return_value=0)
        result = AXUtilitiesText.has_selected_text(test_context.Mock(spec=Atspi.Accessible))
        assert result is False

    def test_get_n_selections_successful(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_n_selections successful case."""

        essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(Atspi.Text, "get_n_selections", return_value=2)
        essential_modules["orca.debug"].print_tokens = test_context.Mock()
        result = AXText.get_n_selections(test_context.Mock(spec=Atspi.Accessible))
        assert result == 2
        essential_modules["orca.debug"].print_tokens.assert_called()

    def test_get_n_selections_with_glib_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_n_selections handles GLib.GError."""

        essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        def raise_glib_error(_obj) -> None:
            raise GLib.GError("Test error")

        test_context.patch_object(Atspi.Text, "get_n_selections", new=raise_glib_error)
        essential_modules["orca.debug"].print_message = test_context.Mock()
        result = AXText.get_n_selections(test_context.Mock(spec=Atspi.Accessible))
        assert result == 0
        essential_modules["orca.debug"].print_message.assert_called()

    def test_get_cached_selected_text_without_cache(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_cached_selected_text without cached data."""

        self._setup_dependencies(test_context)
        from orca.ax_utilities_text import AXUtilitiesText

        result = AXUtilitiesText.get_cached_selected_text(test_context.Mock(spec=Atspi.Accessible))
        assert result == ("", 0, 0)

    @pytest.mark.parametrize(
        "case",
        [
            {
                "id": "fully_contained",
                "rect1": MockRect(x=10, y=10, width=20, height=20),
                "rect2": MockRect(x=5, y=5, width=30, height=30),
                "expected": True,
            },
            {
                "id": "not_contained",
                "rect1": MockRect(x=0, y=0, width=20, height=20),
                "rect2": MockRect(x=5, y=5, width=10, height=10),
                "expected": False,
            },
            {
                "id": "exactly_same",
                "rect1": MockRect(x=10, y=10, width=20, height=20),
                "rect2": MockRect(x=10, y=10, width=20, height=20),
                "expected": True,
            },
        ],
        ids=lambda case: case["id"],
    )
    def test_rect_is_fully_contained_in(self, test_context: OrcaTestContext, case: dict) -> None:
        """Test AXText._rect_is_fully_contained_in."""

        self._setup_dependencies(test_context)
        from orca.ax_utilities_text import AXUtilitiesText

        result = AXUtilitiesText._rect_is_fully_contained_in(case["rect1"], case["rect2"])
        assert result is case["expected"]

    @pytest.mark.parametrize(
        "case",
        [
            {
                "id": "line_inside_clip",
                "line_rect": MockRect(y=10, height=10),
                "clip_rect": MockRect(y=5, height=30),
                "expected": 0,
            },
            {
                "id": "line_above_clip",
                "line_rect": MockRect(y=0, height=10),
                "clip_rect": MockRect(y=15, height=20),
                "expected": -1,
            },
            {
                "id": "line_below_clip",
                "line_rect": MockRect(y=40, height=10),
                "clip_rect": MockRect(y=5, height=20),
                "expected": 1,
            },
        ],
        ids=lambda case: case["id"],
    )
    def test_line_comparison(self, test_context: OrcaTestContext, case: dict) -> None:
        """Test AXText._line_comparison."""

        self._setup_dependencies(test_context)
        from orca.ax_utilities_text import AXUtilitiesText

        result = AXUtilitiesText._line_comparison(case["line_rect"], case["clip_rect"])
        assert result == case["expected"]

    @pytest.mark.parametrize(
        "case",
        [
            {"id": "successful", "should_raise_error": False, "expected_result": ("hello", 5, 10)},
            {"id": "glib_error", "should_raise_error": True, "expected_result": ("", 0, 0)},
        ],
        ids=lambda case: case["id"],
    )
    def test_get_word_at_offset(
        self,
        test_context: OrcaTestContext,
        case: dict,
    ) -> None:
        """Test AXText.get_word_at_offset successful case and GLib.GError handling."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_character_count", return_value=20)
        test_context.patch_object(AXText, "get_caret_offset", return_value=7)

        if case["should_raise_error"]:

            def mock_get_string_at_offset(_obj, _offset, _granularity) -> None:
                raise GLib.GError("Test error")

            test_context.patch(
                "gi.repository.Atspi.Text.get_string_at_offset",
                new=mock_get_string_at_offset,
            )
        else:
            mock_result = test_context.Mock()
            mock_result.content = "hello"
            mock_result.start_offset = 5
            mock_result.end_offset = 10
            test_context.patch(
                "gi.repository.Atspi.Text.get_string_at_offset",
                side_effect=lambda obj, offset, granularity: mock_result,
            )

        result = AXText.get_word_at_offset(test_context.Mock(spec=Atspi.Accessible))
        assert result == case["expected_result"]

    def test_get_all_text_successful(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_all_text."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_character_count", return_value=25)
        test_context.patch(
            "gi.repository.Atspi.Text.get_text",
            return_value="This is some test text",
        )
        result = AXText.get_all_text(test_context.Mock(spec=Atspi.Accessible))
        assert result == "This is some test text"

    def test_get_all_text_with_glib_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_all_text handles GLib.GError exceptions."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_character_count", return_value=25)

        def mock_get_text(_obj, _start, _end) -> None:
            raise GLib.GError("Test error")

        test_context.patch("gi.repository.Atspi.Text.get_text", new=mock_get_text)
        result = AXText.get_all_text(test_context.Mock(spec=Atspi.Accessible))
        assert result == ""

    def test_get_selected_text_with_selection(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_selected_text with text selected."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_selected_ranges", return_value=[(5, 10), (15, 20)])
        test_context.patch_object(
            AXText,
            "get_substring",
            side_effect=lambda obj, start, end: f"text{start}-{end}",
        )
        result = AXUtilitiesText.get_selected_text(test_context.Mock(spec=Atspi.Accessible))
        assert result == ("text5-10 text15-20", 5, 20)

    def test_get_selected_text_without_selection(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_selected_text without text selected."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_selected_ranges", return_value=[])
        result = AXUtilitiesText.get_selected_text(test_context.Mock(spec=Atspi.Accessible))
        assert result == ("", 0, 0)

    def test_get_text_attributes_at_offset_successful(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_text_attributes_at_offset."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)
        test_context.patch_object(AXText, "get_caret_offset", return_value=5)
        test_context.patch_object(
            AXText,
            "get_text_attributes_at_offset",
            return_value=({"font-family": "Arial", "font-size": "12pt"}, 0, 10),
        )
        result = AXText.get_text_attributes_at_offset(test_context.Mock(spec=Atspi.Accessible))
        assert result == ({"font-family": "Arial", "font-size": "12pt"}, 0, 10)

    def test_get_text_attributes_at_offset_with_glib_error(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.get_text_attributes_at_offset handles GLib.GError exceptions."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)
        test_context.patch_object(AXText, "get_caret_offset", return_value=5)
        test_context.patch_object(AXText, "get_character_count", return_value=20)

        def mock_get_attribute_run(_obj, _offset, include_defaults=True) -> None:
            raise GLib.GError("Test error")

        test_context.patch("gi.repository.Atspi.Text.get_attribute_run", new=mock_get_attribute_run)
        result = AXText.get_text_attributes_at_offset(test_context.Mock(spec=Atspi.Accessible))
        assert result == ({}, 0, 20)

    def test_get_text_attributes_at_offset_with_null_attributes(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.get_text_attributes_at_offset when the attribute dict is None."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)
        test_context.patch_object(AXText, "get_caret_offset", return_value=5)
        test_context.patch_object(AXText, "get_character_count", return_value=20)

        # An app that is crashing can return a null hash table for the attributes while still
        # providing the start and end offsets, which PyGObject packs as (None, start, end).
        def mock_get_attribute_run(_obj, _offset, include_defaults=True):
            return None, 0, 10

        test_context.patch("gi.repository.Atspi.Text.get_attribute_run", new=mock_get_attribute_run)
        result = AXText.get_text_attributes_at_offset(test_context.Mock(spec=Atspi.Accessible))
        assert result == ({}, 0, 20)

    def test_get_all_text_attributes_successful(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_all_text_attributes."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText
        from orca.ax_utilities_role import AXUtilitiesRole
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXObject, "supports_text", return_value=True)
        test_context.patch_object(AXText, "get_character_count", return_value=20)
        test_context.patch_object(AXUtilitiesRole, "is_terminal", return_value=False)

        def mock_get_text_attributes_at_offset(_obj, offset) -> tuple[dict[str, str], int, int]:
            if offset < 10:
                return ({"font-weight": "bold"}, 0, 10)
            return ({"font-style": "italic"}, 10, 20)

        test_context.patch_object(
            AXText,
            "get_text_attributes_at_offset",
            new=mock_get_text_attributes_at_offset,
        )
        result = AXUtilitiesText.get_all_text_attributes(test_context.Mock(spec=Atspi.Accessible))
        assert len(result) == 2
        assert result[0] == (0, 10, {"font-weight": "bold"})
        assert result[1] == (10, 20, {"font-style": "italic"})

    def test_supports_paragraph_iteration_true(self, test_context: OrcaTestContext) -> None:
        """Test AXText.supports_paragraph_iteration returns True."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(
            AXText,
            "get_paragraph_at_offset",
            return_value=("This is a paragraph.", 0, 20),
        )
        result = AXUtilitiesText.supports_paragraph_iteration(
            test_context.Mock(spec=Atspi.Accessible)
        )
        assert result is True

    def test_iter_character_with_valid_text(self, test_context: OrcaTestContext) -> None:
        """Test AXText.iter_character with valid text."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_caret_offset", return_value=0)
        test_context.patch_object(AXText, "get_character_count", return_value=3)

        def mock_get_character_at_offset(_obj, offset) -> tuple[str, int, int]:
            chars = [("a", 0, 1), ("b", 1, 2), ("c", 2, 3)]
            if 0 <= offset < 3:
                return chars[offset]
            return ("", 0, 0)

        test_context.patch_object(
            AXText,
            "get_character_at_offset",
            new=mock_get_character_at_offset,
        )
        result = list(AXUtilitiesText.iter_character(test_context.Mock(spec=Atspi.Accessible)))
        assert result == [("a", 0, 1), ("b", 1, 2), ("c", 2, 3)]

    def test_iter_character_with_empty_text(self, test_context: OrcaTestContext) -> None:
        """Test AXText.iter_character with empty text."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_caret_offset", return_value=0)
        test_context.patch_object(AXText, "get_character_count", return_value=0)
        result = list(AXUtilitiesText.iter_character(test_context.Mock(spec=Atspi.Accessible)))
        assert not result

    def test_iter_word_with_valid_text(self, test_context: OrcaTestContext) -> None:
        """Test AXText.iter_word with valid text."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_caret_offset", return_value=0)
        test_context.patch_object(AXText, "get_character_count", return_value=11)

        def mock_get_word_at_offset(_obj, offset) -> tuple[str, int, int]:
            if offset is None:
                offset = 0
            if 0 <= offset < 5:
                return ("hello", 0, 5)
            if 5 <= offset < 11:
                return (" world", 5, 11)
            return ("", 0, 0)

        test_context.patch_object(AXText, "get_word_at_offset", new=mock_get_word_at_offset)
        result = list(AXUtilitiesText.iter_word(test_context.Mock(spec=Atspi.Accessible)))
        assert result == [("hello", 0, 5), (" world", 5, 11)]

    def test_iter_line_with_valid_text(self, test_context: OrcaTestContext) -> None:
        """Test AXText.iter_line with valid text."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_caret_offset", return_value=0)
        test_context.patch_object(AXText, "get_character_count", return_value=20)

        def mock_get_line_at_offset(_obj, offset) -> tuple[str, int, int]:
            if offset is None:
                offset = 0
            if 0 <= offset < 10:
                return ("First line", 0, 10)
            if 10 <= offset < 20:
                return ("Second line", 10, 20)
            return ("", 0, 0)

        test_context.patch_object(AXText, "get_line_at_offset", new=mock_get_line_at_offset)
        result = list(AXUtilitiesText.iter_line(test_context.Mock(spec=Atspi.Accessible)))
        assert result == [("First line", 0, 10), ("Second line", 10, 20)]

    def test_iter_line_prevents_infinite_loop_when_get_next_line_returns_same_position(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test that iter_line prevents infinite loops when get_next_line doesn't advance."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_caret_offset", return_value=0)

        def mock_get_line_at_offset(_obj, offset) -> tuple[str, int, int]:
            if offset is None:
                offset = 0
            return ("Test line", 0, 10)

        def mock_get_next_line(_obj, _offset) -> tuple[str, int, int]:
            return ("Next line", 0, 10)

        test_context.patch_object(AXText, "get_line_at_offset", new=mock_get_line_at_offset)
        test_context.patch_object(AXUtilitiesText, "get_next_line", new=mock_get_next_line)

        result = list(AXUtilitiesText.iter_line(test_context.Mock(spec=Atspi.Accessible)))
        assert result == [("Test line", 0, 10)]

    def test_iter_line_skips_duplicate_when_offset_at_end(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Ensure iter_line doesn't yield the same line when starting at its end offset."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        # get_line_at_offset returns the same first line even when called at end
        test_context.patch_object(
            AXText,
            "get_line_at_offset",
            new=lambda _obj, _offset: ("First line", 0, 10),
        )

        # get_next_line advances to a distinct second line only once
        def mock_get_next_line(_obj, offset) -> tuple[str, int, int]:
            if offset == 0:
                return ("Second line", 10, 20)
            return ("", 0, 0)

        test_context.patch_object(AXUtilitiesText, "get_next_line", new=mock_get_next_line)

        result = list(AXUtilitiesText.iter_line(test_context.Mock(spec=Atspi.Accessible), 10))
        assert result == [("Second line", 10, 20)]

    def test_iter_sentence_prevents_infinite_loop_when_get_next_sentence_returns_same_position(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test that iter_sentence prevents infinite loops if get_next_sentence doesn't advance."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_caret_offset", return_value=0)

        def mock_get_sentence_at_offset(_obj, offset) -> tuple[str, int, int]:
            if offset is None:
                offset = 0
            return ("Test sentence", 0, 15)

        def mock_get_next_sentence(_obj, _offset) -> tuple[str, int, int]:
            return ("Next sentence", 0, 15)

        test_context.patch_object(AXText, "get_sentence_at_offset", new=mock_get_sentence_at_offset)
        test_context.patch_object(AXUtilitiesText, "get_next_sentence", new=mock_get_next_sentence)

        result = list(AXUtilitiesText.iter_sentence(test_context.Mock(spec=Atspi.Accessible)))
        assert result == [("Test sentence", 0, 15)]

    def test_iter_sentence_skips_duplicate_when_offset_at_end(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Ensure iter_sentence doesn't yield the same sentence when starting at its end."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        # get_sentence_at_offset returns the same first sentence even at its end
        test_context.patch_object(
            AXText,
            "get_sentence_at_offset",
            new=lambda _obj, _offset: ("First sentence.", 0, 12),
        )

        # get_next_sentence advances to a distinct second sentence only once
        def mock_get_next_sentence(_obj, offset) -> tuple[str, int, int]:
            if offset == 0:
                return ("Second sentence.", 12, 25)
            return ("", 0, 0)

        test_context.patch_object(AXUtilitiesText, "get_next_sentence", new=mock_get_next_sentence)

        result = list(AXUtilitiesText.iter_sentence(test_context.Mock(spec=Atspi.Accessible), 12))
        assert result == [("Second sentence.", 12, 25)]

    def test_iter_sentence_with_valid_text(self, test_context: OrcaTestContext) -> None:
        """Test AXText.iter_sentence with valid text."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_caret_offset", return_value=0)
        test_context.patch_object(AXText, "get_character_count", return_value=25)

        def mock_get_sentence_at_offset(_obj, offset) -> tuple[str, int, int]:
            if offset is None:
                offset = 0
            if 0 <= offset < 12:
                return ("First sentence.", 0, 12)
            if 12 <= offset < 25:
                return ("Second sentence.", 12, 25)
            return ("", 0, 0)

        test_context.patch_object(AXText, "get_sentence_at_offset", new=mock_get_sentence_at_offset)
        result = list(AXUtilitiesText.iter_sentence(test_context.Mock(spec=Atspi.Accessible)))
        assert result == [("First sentence.", 0, 12), ("Second sentence.", 12, 25)]

    def test_iter_paragraph_with_valid_text(self, test_context: OrcaTestContext) -> None:
        """Test AXText.iter_paragraph with valid text."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_caret_offset", return_value=0)
        test_context.patch_object(AXText, "get_character_count", return_value=30)

        def mock_get_paragraph_at_offset(_obj, offset) -> tuple[str, int, int]:
            if offset is None:
                offset = 0
            if 0 <= offset < 15:
                return ("First paragraph.", 0, 15)
            if 15 <= offset < 30:
                return ("Second paragraph.", 15, 30)
            return ("", 0, 0)

        test_context.patch_object(
            AXText,
            "get_paragraph_at_offset",
            new=mock_get_paragraph_at_offset,
        )
        result = list(AXUtilitiesText.iter_paragraph(test_context.Mock(spec=Atspi.Accessible)))
        assert result == [("First paragraph.", 0, 15), ("Second paragraph.", 15, 30)]

    @pytest.mark.parametrize(
        "case",
        [
            {
                "id": "offset_successful",
                "method_type": "offset",
                "offset_result": 25,
                "char_count": 30,
                "expected_result": 25,
                "should_raise_error": False,
                "debug_method": "print_tokens",
                "x_coord": 100,
                "y_coord": 200,
            },
            {
                "id": "offset_glib_error",
                "method_type": "offset",
                "offset_result": -1,
                "char_count": 30,
                "expected_result": -1,
                "should_raise_error": True,
                "debug_method": "print_message",
                "x_coord": 100,
                "y_coord": 200,
            },
            {
                "id": "character_valid",
                "method_type": "character",
                "offset_result": 5,
                "char_count": 10,
                "expected_result": ("a", 5, 6),
                "should_raise_error": False,
                "debug_method": None,
                "x_coord": 100,
                "y_coord": 200,
            },
            {
                "id": "character_invalid_offset",
                "method_type": "character",
                "offset_result": -1,
                "char_count": 10,
                "expected_result": ("", 0, 0),
                "should_raise_error": False,
                "debug_method": None,
                "x_coord": 100,
                "y_coord": 200,
            },
            {
                "id": "word_valid",
                "method_type": "word",
                "offset_result": 5,
                "char_count": 10,
                "expected_result": ("hello", 3, 8),
                "should_raise_error": False,
                "debug_method": None,
                "x_coord": 100,
                "y_coord": 200,
            },
            {
                "id": "line_valid",
                "method_type": "line",
                "offset_result": 5,
                "char_count": 20,
                "expected_result": ("This is a line", 0, 14),
                "should_raise_error": False,
                "debug_method": None,
                "x_coord": 100,
                "y_coord": 200,
            },
            {
                "id": "sentence_valid",
                "method_type": "sentence",
                "offset_result": 5,
                "char_count": 20,
                "expected_result": ("This is a sentence.", 0, 19),
                "should_raise_error": False,
                "debug_method": None,
                "x_coord": 100,
                "y_coord": 200,
            },
            {
                "id": "paragraph_valid",
                "method_type": "paragraph",
                "offset_result": 5,
                "char_count": 25,
                "expected_result": ("This is a paragraph.", 0, 20),
                "should_raise_error": False,
                "debug_method": None,
                "x_coord": 100,
                "y_coord": 200,
            },
        ],
        ids=lambda case: case["id"],
    )
    def test_get_at_point_methods_scenarios(  # pylint: disable=too-many-locals
        self,
        test_context: OrcaTestContext,
        case: dict,
    ) -> None:
        """Test AXText get_*_at_point methods with various scenarios."""

        essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        mock_obj = test_context.Mock(spec=Atspi.Accessible)
        result: int | tuple[str, int, int] | None = None

        if case["method_type"] == "offset":
            if case["should_raise_error"]:

                def raise_glib_error(_obj, _x, _y, _coord_type) -> None:
                    raise GLib.GError("Test error")

                test_context.patch_object(Atspi.Text, "get_offset_at_point", new=raise_glib_error)
            else:
                test_context.patch_object(
                    Atspi.Text,
                    "get_offset_at_point",
                    side_effect=lambda obj, x, y, coord_type: case["offset_result"],
                )
            essential_modules["orca.debug"].print_tokens = test_context.Mock()
            essential_modules["orca.debug"].print_message = test_context.Mock()
            result = AXText.get_offset_at_point(mock_obj, case["x_coord"], case["y_coord"])
            if case["debug_method"]:
                getattr(essential_modules["orca.debug"], case["debug_method"]).assert_called()
        else:
            test_context.patch_object(
                AXText,
                "get_offset_at_point",
                side_effect=lambda obj, x, y: case["offset_result"],
            )
            test_context.patch_object(
                AXText,
                "get_character_count",
                side_effect=lambda obj: case["char_count"],
            )

            if case["method_type"] == "character":
                if case["offset_result"] == -1:
                    result = AXUtilitiesText.get_character_at_point(
                        mock_obj,
                        case["x_coord"],
                        case["y_coord"],
                    )
                else:
                    test_context.patch_object(
                        AXText,
                        "get_character_at_offset",
                        side_effect=lambda obj, offset: case["expected_result"],
                    )
                    result = AXUtilitiesText.get_character_at_point(
                        mock_obj,
                        case["x_coord"],
                        case["y_coord"],
                    )
            elif case["method_type"] == "word":
                test_context.patch_object(
                    AXText,
                    "get_word_at_offset",
                    side_effect=lambda obj, offset: case["expected_result"],
                )
                result = AXUtilitiesText.get_word_at_point(
                    mock_obj, case["x_coord"], case["y_coord"]
                )
            elif case["method_type"] == "line":
                test_context.patch_object(
                    AXText,
                    "get_line_at_offset",
                    side_effect=lambda obj, offset: case["expected_result"],
                )
                result = AXUtilitiesText.get_line_at_point(
                    mock_obj, case["x_coord"], case["y_coord"]
                )
            elif case["method_type"] == "sentence":
                test_context.patch_object(
                    AXText,
                    "get_sentence_at_offset",
                    side_effect=lambda obj, offset: case["expected_result"],
                )
                result = AXUtilitiesText.get_sentence_at_point(
                    mock_obj, case["x_coord"], case["y_coord"]
                )
            elif case["method_type"] == "paragraph":
                test_context.patch_object(
                    AXText,
                    "get_paragraph_at_offset",
                    side_effect=lambda obj, offset: case["expected_result"],
                )
                result = AXUtilitiesText.get_paragraph_at_point(
                    mock_obj, case["x_coord"], case["y_coord"]
                )

        assert result == case["expected_result"]

    def test_string_has_spelling_error_with_invalid_spelling(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.string_has_spelling_error with spelling error."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(
            AXText,
            "get_text_attributes_at_offset",
            return_value=({"invalid": "spelling"}, 0, 10),
        )
        result = AXUtilitiesText.string_has_spelling_error(
            test_context.Mock(spec=Atspi.Accessible), 5
        )
        assert result is True

    def test_string_has_spelling_error_with_text_spelling(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.string_has_spelling_error with text-spelling misspelled."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(
            AXText,
            "get_text_attributes_at_offset",
            return_value=({"text-spelling": "misspelled"}, 0, 10),
        )
        result = AXUtilitiesText.string_has_spelling_error(
            test_context.Mock(spec=Atspi.Accessible), 5
        )
        assert result is True

    def test_string_has_spelling_error_with_underline_error(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.string_has_spelling_error with underline error."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(
            AXText,
            "get_text_attributes_at_offset",
            return_value=({"underline": "spelling"}, 0, 10),
        )
        result = AXUtilitiesText.string_has_spelling_error(
            test_context.Mock(spec=Atspi.Accessible), 5
        )
        assert result is True

    def test_string_has_spelling_error_without_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.string_has_spelling_error without spelling error."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(
            AXText,
            "get_text_attributes_at_offset",
            return_value=({"font-size": "12pt"}, 0, 10),
        )
        result = AXUtilitiesText.string_has_spelling_error(
            test_context.Mock(spec=Atspi.Accessible), 5
        )
        assert result is False

    def test_string_has_grammar_error_with_invalid_grammar(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.string_has_grammar_error with grammar error."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(
            AXText,
            "get_text_attributes_at_offset",
            return_value=({"invalid": "grammar"}, 0, 10),
        )
        result = AXUtilitiesText.string_has_grammar_error(
            test_context.Mock(spec=Atspi.Accessible), 5
        )
        assert result is True

    def test_string_has_grammar_error_with_underline_grammar(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.string_has_grammar_error with underline grammar."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(
            AXText,
            "get_text_attributes_at_offset",
            return_value=({"underline": "grammar"}, 0, 10),
        )
        result = AXUtilitiesText.string_has_grammar_error(
            test_context.Mock(spec=Atspi.Accessible), 5
        )
        assert result is True

    def test_string_has_grammar_error_without_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.string_has_grammar_error without grammar error."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(
            AXText,
            "get_text_attributes_at_offset",
            return_value=({"font-size": "12pt"}, 0, 10),
        )
        result = AXUtilitiesText.string_has_grammar_error(
            test_context.Mock(spec=Atspi.Accessible), 5
        )
        assert result is False

    @pytest.mark.parametrize(
        "case",
        [
            {
                "id": "start_with_selection",
                "method_name": "get_selection_start_offset",
                "has_selection": True,
                "expected_result": 5,
            },
            {
                "id": "start_without_selection",
                "method_name": "get_selection_start_offset",
                "has_selection": False,
                "expected_result": -1,
            },
            {
                "id": "end_with_selection",
                "method_name": "get_selection_end_offset",
                "has_selection": True,
                "expected_result": 20,
            },
            {
                "id": "end_without_selection",
                "method_name": "get_selection_end_offset",
                "has_selection": False,
                "expected_result": -1,
            },
        ],
        ids=lambda case: case["id"],
    )
    def test_selection_offset_methods(
        self,
        test_context: OrcaTestContext,
        case: dict,
    ) -> None:
        """Test AXText selection offset methods with and without selection."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        selected_ranges = [(5, 10), (15, 20)] if case["has_selection"] else []
        test_context.patch_object(AXText, "get_selected_ranges", return_value=selected_ranges)

        method = getattr(AXUtilitiesText, case["method_name"])
        result = method(test_context.Mock(spec=Atspi.Accessible))
        assert result == case["expected_result"]

    def test_is_all_text_selected_with_full_selection(self, test_context: OrcaTestContext) -> None:
        """Test AXText.is_all_text_selected with full text selected."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_character_count", return_value=20)
        test_context.patch_object(AXText, "get_selected_ranges", return_value=[(0, 20)])
        result = AXUtilitiesText.is_all_text_selected(test_context.Mock(spec=Atspi.Accessible))
        assert result is True

    def test_is_all_text_selected_with_partial_selection(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.is_all_text_selected with partial selection."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_character_count", return_value=20)
        test_context.patch_object(AXText, "get_selected_ranges", return_value=[(5, 15)])
        result = AXUtilitiesText.is_all_text_selected(test_context.Mock(spec=Atspi.Accessible))
        assert result is False

    def test_is_all_text_selected_without_selection(self, test_context: OrcaTestContext) -> None:
        """Test AXText.is_all_text_selected without selection."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_character_count", return_value=20)
        test_context.patch_object(AXText, "get_selected_ranges", return_value=[])
        result = AXUtilitiesText.is_all_text_selected(test_context.Mock(spec=Atspi.Accessible))
        assert result is False

    def test_clear_all_selected_text(self, test_context: OrcaTestContext) -> None:
        """Test AXText.clear_all_selected_text."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        removed_selections = []

        def mock_remove_selection(_obj, selection_number) -> None:
            removed_selections.append(selection_number)

        test_context.patch_object(AXText, "get_n_selections", return_value=3)
        test_context.patch_object(AXText, "remove_selection", new=mock_remove_selection)
        AXUtilitiesText.clear_all_selected_text(test_context.Mock(spec=Atspi.Accessible))
        assert removed_selections == [0, 1, 2]

    def test_remove_selection(self, test_context: OrcaTestContext) -> None:
        """Test AXText.remove_selection."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)
        mock_remove_selection = test_context.Mock()
        test_context.patch("gi.repository.Atspi.Text.remove_selection", new=mock_remove_selection)
        mock_accessible = test_context.Mock(spec=Atspi.Accessible)
        AXText.remove_selection(mock_accessible, 0)
        mock_remove_selection.assert_called_once_with(mock_accessible, 0)

    def test_remove_selection_with_glib_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.remove_selection handles GLib.GError."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)

        def mock_remove_selection(obj, selection_number) -> None:
            raise GLib.GError("Test error")

        test_context.patch("gi.repository.Atspi.Text.remove_selection", new=mock_remove_selection)

        AXText.remove_selection(test_context.Mock(spec=Atspi.Accessible), 0)

    def test_get_selected_ranges_with_valid_selections(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_selected_ranges with valid selections."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_n_selections", return_value=2)

        def mock_get_selection(_obj, selection_num) -> None:
            results = [
                test_context.Mock(start_offset=5, end_offset=10),
                test_context.Mock(start_offset=15, end_offset=20),
            ]
            return results[selection_num]

        test_context.patch("gi.repository.Atspi.Text.get_selection", new=mock_get_selection)
        result = AXText.get_selected_ranges(test_context.Mock(spec=Atspi.Accessible))
        assert result == [(5, 10), (15, 20)]

    def test_get_selected_ranges_with_glib_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_selected_ranges handles GLib.GError."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_n_selections", return_value=1)

        def mock_get_selection(obj, selection_num) -> None:
            raise GLib.GError("Test error")

        test_context.patch("gi.repository.Atspi.Text.get_selection", new=mock_get_selection)
        result = AXText.get_selected_ranges(test_context.Mock(spec=Atspi.Accessible))
        assert not result

    def test_add_new_selection_successful(self, test_context: OrcaTestContext) -> None:
        """Test AXText.add_new_selection successful case."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)
        test_context.patch("gi.repository.Atspi.Text.add_selection", return_value=True)
        result = AXText.add_new_selection(test_context.Mock(spec=Atspi.Accessible), 5, 10)
        assert result is True

    def test_add_new_selection_with_glib_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.add_new_selection handles GLib.GError."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)

        def mock_add_selection(obj, start, end) -> None:
            raise GLib.GError("Test error")

        test_context.patch("gi.repository.Atspi.Text.add_selection", new=mock_add_selection)
        result = AXText.add_new_selection(test_context.Mock(spec=Atspi.Accessible), 5, 10)
        assert result is False

    def test_update_existing_selection_successful(self, test_context: OrcaTestContext) -> None:
        """Test AXText.update_existing_selection successful case."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)
        test_context.patch("gi.repository.Atspi.Text.set_selection", return_value=True)
        result = AXText.update_existing_selection(
            test_context.Mock(spec=Atspi.Accessible),
            5,
            10,
            0,
        )
        assert result is True

    def test_update_existing_selection_with_glib_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.update_existing_selection handles GLib.GError."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)

        def mock_set_selection(obj, num, start, end) -> None:
            raise GLib.GError("Test error")

        test_context.patch("gi.repository.Atspi.Text.set_selection", new=mock_set_selection)
        result = AXText.update_existing_selection(
            test_context.Mock(spec=Atspi.Accessible),
            5,
            10,
            0,
        )
        assert result is False

    def test_set_selected_text_with_existing_selection(self, test_context: OrcaTestContext) -> None:
        """Test AXText.set_selected_text with existing selection."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_n_selections", return_value=1)
        test_context.patch_object(AXText, "update_existing_selection", return_value=True)
        test_context.patch_object(AXText, "get_substring", return_value="test")
        test_context.patch_object(
            AXUtilitiesText, "get_selected_text", return_value=("test", 5, 10)
        )
        essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context)
        essential_modules["orca.debug"].debugLevel = 0
        result = AXUtilitiesText.set_selected_text(test_context.Mock(spec=Atspi.Accessible), 5, 10)
        assert result is True

    def test_set_selected_text_with_new_selection(self, test_context: OrcaTestContext) -> None:
        """Test AXText.set_selected_text with new selection."""

        essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXText, "get_n_selections", return_value=0)
        test_context.patch_object(AXText, "add_new_selection", return_value=True)
        test_context.patch_object(AXText, "get_substring", return_value="test")
        test_context.patch_object(
            AXUtilitiesText, "get_selected_text", return_value=("test", 5, 10)
        )
        essential_modules["orca.debug"].debugLevel = 0
        result = AXUtilitiesText.set_selected_text(test_context.Mock(spec=Atspi.Accessible), 5, 10)
        assert result is True

    def test_update_cached_selected_text(self, test_context: OrcaTestContext) -> None:
        """Test AXText.update_cached_selected_text."""

        self._setup_dependencies(test_context)
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(
            AXUtilitiesText, "get_selected_text", return_value=("cached text", 5, 15)
        )
        mock_accessible = test_context.Mock(spec=Atspi.Accessible)
        AXUtilitiesText.update_cached_selected_text(mock_accessible)
        assert AXUtilitiesText.get_cached_selected_text(mock_accessible) == ("cached text", 5, 15)

    def test_get_character_rect_successful(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_character_rect successful case."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)
        test_context.patch_object(AXText, "get_caret_offset", return_value=5)
        mock_rect = test_context.Mock(x=10, y=20, width=5, height=12)
        test_context.patch(
            "gi.repository.Atspi.Text.get_character_extents",
            side_effect=lambda obj, offset, coord_type: mock_rect,
        )
        result = AXText.get_character_rect(test_context.Mock(spec=Atspi.Accessible), 5)
        assert result == mock_rect

    def test_get_character_rect_with_glib_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_character_rect handles GLib.GError."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)
        test_context.patch_object(AXText, "get_caret_offset", return_value=5)

        def mock_get_character_extents(obj, offset, coord_type) -> None:
            raise GLib.GError("Test error")

        test_context.patch(
            "gi.repository.Atspi.Text.get_character_extents",
            new=mock_get_character_extents,
        )
        result = AXText.get_character_rect(test_context.Mock(spec=Atspi.Accessible), 5)
        assert result.x == 0 and result.y == 0

    def test_get_range_rect_successful(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_range_rect successful case."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)
        mock_rect = test_context.Mock(x=10, y=20, width=50, height=12)
        test_context.patch(
            "gi.repository.Atspi.Text.get_range_extents",
            side_effect=lambda obj, start, end, coord_type: mock_rect,
        )
        result = AXText.get_range_rect(test_context.Mock(spec=Atspi.Accessible), 5, 15)
        assert result == mock_rect

    def test_get_range_rect_with_glib_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_range_rect handles GLib.GError."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_text import AXText

        test_context.patch_object(AXObject, "supports_text", return_value=True)

        def mock_get_range_extents(obj, start, end, coord_type) -> None:
            raise GLib.GError("Test error")

        test_context.patch("gi.repository.Atspi.Text.get_range_extents", new=mock_get_range_extents)
        result = AXText.get_range_rect(test_context.Mock(spec=Atspi.Accessible), 5, 15)
        assert result.x == 0 and result.y == 0

    def test_scroll_substring_to_point_successful(self, test_context: OrcaTestContext) -> None:
        """Test AXText.scroll_substring_to_point successful case."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_character_count", return_value=20)
        test_context.patch(
            "gi.repository.Atspi.Text.scroll_substring_to_point",
            return_value=True,
        )
        result = AXText.scroll_substring_to_point(
            test_context.Mock(spec=Atspi.Accessible),
            100,
            200,
            5,
            15,
        )
        assert result is True

    def test_scroll_substring_to_point_with_glib_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.scroll_substring_to_point handles GLib.GError."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_character_count", return_value=20)

        def mock_scroll_substring_to_point(obj, start, end, coord_type, x, y) -> None:
            raise GLib.GError("Test error")

        test_context.patch(
            "gi.repository.Atspi.Text.scroll_substring_to_point",
            new=mock_scroll_substring_to_point,
        )
        result = AXText.scroll_substring_to_point(
            test_context.Mock(spec=Atspi.Accessible),
            100,
            200,
            5,
            15,
        )
        assert result is False

    def test_scroll_substring_to_location_successful(self, test_context: OrcaTestContext) -> None:
        """Test AXText.scroll_substring_to_location successful case."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_character_count", return_value=20)
        mock_scroll_type = test_context.Mock()

        def mock_scroll_success(_obj, _start, _end, _location) -> bool:
            return True

        test_context.patch("gi.repository.Atspi.Text.scroll_substring_to", new=mock_scroll_success)
        mock_accessible = test_context.Mock(spec=Atspi.Accessible)
        result = AXText.scroll_substring_to_location(mock_accessible, mock_scroll_type, 5, 15)
        assert result is True

    def test_scroll_substring_to_location_with_glib_error(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.scroll_substring_to_location handles GLib.GError."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_character_count", return_value=20)

        def mock_scroll_substring_to(obj, start, end, location) -> None:
            raise GLib.GError("Test error")

        mock_scroll_type = test_context.Mock()
        scroll_attr = "gi.repository.Atspi.Text.scroll_substring_to"
        test_context.patch(scroll_attr, new=mock_scroll_substring_to)
        mock_accessible = test_context.Mock(spec=Atspi.Accessible)
        result = AXText.scroll_substring_to_location(mock_accessible, mock_scroll_type, 5, 15)
        assert result is False

    def test_get_visible_lines(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_visible_lines."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        clip_rect = test_context.Mock(x=0, y=0, width=100, height=50)
        test_context.patch_object(
            AXUtilitiesText,
            "find_first_visible_line",
            return_value=("First line", 0, 10),
        )

        def mock_iter_line(_obj, _offset) -> Generator[tuple[str, int, int], None, None]:
            lines = [("Second line", 10, 20), ("Third line", 20, 30)]
            yield from lines

        test_context.patch_object(AXUtilitiesText, "iter_line", new=mock_iter_line)
        test_context.patch_object(
            AXText,
            "get_range_rect",
            side_effect=lambda obj, start, end: test_context.Mock(x=0, y=10, width=50, height=10),
        )
        test_context.patch_object(AXUtilitiesText, "_line_comparison", return_value=0)
        result = AXUtilitiesText.get_visible_lines(
            test_context.Mock(spec=Atspi.Accessible), clip_rect
        )
        assert len(result) >= 1
        assert result[0] == ("First line", 0, 10)

    def test_find_first_visible_line_at_start(self, test_context: OrcaTestContext) -> None:
        """Test AXText.find_first_visible_line when first line is at start."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        clip_rect = test_context.Mock(x=0, y=0, width=100, height=50)
        test_context.patch_object(AXText, "get_character_count", return_value=100)
        test_context.patch_object(AXText, "get_line_at_offset", return_value=("First line", 0, 10))
        result = AXUtilitiesText.find_first_visible_line(
            test_context.Mock(spec=Atspi.Accessible), clip_rect
        )
        assert result == ("First line", 0, 10)

    def test_find_last_visible_line_at_end(self, test_context: OrcaTestContext) -> None:
        """Test AXText.find_last_visible_line when last line is at end."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        clip_rect = test_context.Mock(x=0, y=0, width=100, height=50)
        test_context.patch_object(AXText, "get_character_count", return_value=100)
        test_context.patch_object(AXText, "get_line_at_offset", return_value=("Last line", 90, 100))
        result = AXUtilitiesText.find_last_visible_line(
            test_context.Mock(spec=Atspi.Accessible), clip_rect
        )
        assert result == ("Last line", 90, 100)

    @pytest.mark.parametrize(
        "case",
        [
            {
                "id": "successful",
                "should_raise_error": False,
                "expected_result": ("This is a line", 0, 14),
            },
            {"id": "glib_error", "should_raise_error": True, "expected_result": ("", 0, 0)},
        ],
        ids=lambda case: case["id"],
    )
    def test_get_line_at_offset(
        self,
        test_context: OrcaTestContext,
        case: dict,
    ) -> None:
        """Test AXText.get_line_at_offset successful case and GLib.GError handling."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_character_count", return_value=20)
        test_context.patch_object(AXText, "get_caret_offset", return_value=7)

        if case["should_raise_error"]:

            def mock_get_string_at_offset(_obj, _offset, _granularity) -> None:
                raise GLib.GError("Test error")

            test_context.patch(
                "gi.repository.Atspi.Text.get_string_at_offset",
                new=mock_get_string_at_offset,
            )
        else:
            mock_result = test_context.Mock()
            mock_result.content = "This is a line"
            mock_result.start_offset = 0
            mock_result.end_offset = 14
            test_context.patch(
                "gi.repository.Atspi.Text.get_string_at_offset",
                side_effect=lambda obj, offset, granularity: mock_result,
            )

        result = AXText.get_line_at_offset(test_context.Mock(spec=Atspi.Accessible))
        assert result == case["expected_result"]

    def test_get_line_at_offset_chromium_fallback(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_line_at_offset with Chromium fallback for invalid result."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_character_count", return_value=20)
        test_context.patch_object(AXText, "get_caret_offset", return_value=20)
        call_count = 0

        def mock_get_string_at_offset(_obj, _offset, _granularity):
            nonlocal call_count
            call_count += 1
            mock_result = test_context.Mock()
            if call_count == 1:
                mock_result.content = ""
                mock_result.start_offset = -1
                mock_result.end_offset = -1
            else:
                mock_result.content = "Valid line"
                mock_result.start_offset = 10
                mock_result.end_offset = 20
            return mock_result

        test_context.patch(
            "gi.repository.Atspi.Text.get_string_at_offset",
            new=mock_get_string_at_offset,
        )
        result = AXText.get_line_at_offset(test_context.Mock(spec=Atspi.Accessible), 20)
        assert result == ("Valid line", 10, 20)

    @pytest.mark.parametrize(
        "case",
        [
            {
                "id": "successful",
                "should_raise_error": False,
                "expected_result": ("This is a sentence.", 0, 19),
            },
            {"id": "glib_error", "should_raise_error": True, "expected_result": ("", 0, 0)},
        ],
        ids=lambda case: case["id"],
    )
    def test_get_sentence_at_offset(
        self,
        test_context: OrcaTestContext,
        case: dict,
    ) -> None:
        """Test AXText.get_sentence_at_offset successful case and GLib.GError handling."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_character_count", return_value=25)
        test_context.patch_object(AXText, "get_caret_offset", return_value=7)

        if case["should_raise_error"]:

            def mock_get_string_at_offset(_obj, _offset, _granularity) -> None:
                raise GLib.GError("Test error")

            test_context.patch(
                "gi.repository.Atspi.Text.get_string_at_offset",
                new=mock_get_string_at_offset,
            )
            test_context.patch("gi.repository.Atspi.Text.get_text", return_value="")
        else:
            mock_result = test_context.Mock()
            mock_result.content = "This is a sentence."
            mock_result.start_offset = 0
            mock_result.end_offset = 19
            test_context.patch(
                "gi.repository.Atspi.Text.get_string_at_offset",
                side_effect=lambda obj, offset, granularity: mock_result,
            )

        result = AXText.get_sentence_at_offset(test_context.Mock(spec=Atspi.Accessible))
        assert result == case["expected_result"]

    @pytest.mark.parametrize(
        "case",
        [
            {
                "id": "successful",
                "should_raise_error": False,
                "expected_result": ("This is a paragraph.", 0, 20),
            },
            {"id": "glib_error", "should_raise_error": True, "expected_result": ("", 0, 0)},
        ],
        ids=lambda case: case["id"],
    )
    def test_get_paragraph_at_offset(
        self,
        test_context: OrcaTestContext,
        case: dict,
    ) -> None:
        """Test AXText.get_paragraph_at_offset successful case and GLib.GError handling."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_character_count", return_value=25)
        test_context.patch_object(AXText, "get_caret_offset", return_value=7)

        if case["should_raise_error"]:

            def mock_get_string_at_offset(_obj, _offset, _granularity) -> None:
                raise GLib.GError("Test error")

            test_context.patch(
                "gi.repository.Atspi.Text.get_string_at_offset",
                new=mock_get_string_at_offset,
            )
        else:
            mock_result = test_context.Mock()
            mock_result.content = "This is a paragraph."
            mock_result.start_offset = 0
            mock_result.end_offset = 20
            test_context.patch(
                "gi.repository.Atspi.Text.get_string_at_offset",
                side_effect=lambda obj, offset, granularity: mock_result,
            )

        result = AXText.get_paragraph_at_offset(test_context.Mock(spec=Atspi.Accessible))
        assert result == case["expected_result"]

    def test_get_character_at_offset_invalid_offset(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_character_at_offset with invalid offset."""

        essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        essential_modules["orca.debug"].print_message = test_context.Mock()
        test_context.patch("gi.repository.Atspi.Text.get_character_count", return_value=10)
        result = AXText.get_character_at_offset(test_context.Mock(spec=Atspi.Accessible), 15)
        assert result == ("", 0, 0)
        essential_modules["orca.debug"].print_message.assert_called()

    def test_get_substring_with_end_offset_minus_one(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_substring with end_offset=-1."""

        essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch_object(AXText, "get_character_count", return_value=20)
        test_context.patch_object(Atspi.Text, "get_text", return_value="substring")
        essential_modules["orca.debug"].print_tokens = test_context.Mock()
        result = AXText.get_substring(test_context.Mock(spec=Atspi.Accessible), 5, -1)
        assert result == "substring"

    def test_supports_paragraph_iteration_false(self, test_context: OrcaTestContext) -> None:
        """Test AXText.supports_paragraph_iteration returns False."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXObject, "supports_text", return_value=False)
        result = AXUtilitiesText.supports_paragraph_iteration(
            test_context.Mock(spec=Atspi.Accessible)
        )
        assert result is False

    def test_is_whitespace_or_empty_no_text_support(self, test_context: OrcaTestContext) -> None:
        """Test AXText.is_whitespace_or_empty with no text support."""

        self._setup_dependencies(test_context)
        from orca.ax_object import AXObject
        from orca.ax_utilities_text import AXUtilitiesText

        test_context.patch_object(AXObject, "supports_text", return_value=False)
        result = AXUtilitiesText.is_whitespace_or_empty(test_context.Mock(spec=Atspi.Accessible))
        assert result is True

    @pytest.mark.parametrize(
        "case",
        [
            {"id": "matching_locale", "attribute_value": "en", "expected": True},
            {"id": "non_matching_locale", "attribute_value": "fr", "expected": False},
        ],
        ids=lambda case: case["id"],
    )
    def test_value_is_default_language_attribute(
        self,
        test_context: OrcaTestContext,
        case: dict,
    ) -> None:
        """Test AXTextAttribute.value_is_default for LANGUAGE attribute."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXTextAttribute

        test_context.patch("locale.getlocale", return_value=("en_US", "UTF-8"))
        result = AXTextAttribute.LANGUAGE.value_is_default(case["attribute_value"])
        assert result is case["expected"]

    @pytest.mark.parametrize(
        "case",
        [
            {
                "id": "next_character_successful",
                "direction": "next",
                "unit_type": "character",
                "setup_params": {
                    "caret_offset": 0,
                    "count": 5,
                    "data": {"0": ("a", 0, 1), "1": ("b", 1, 2), "2": ("c", 2, 3)},
                },
                "method_params": {},
                "expected": ("b", 1, 2),
            },
            {
                "id": "next_character_with_offset",
                "direction": "next",
                "unit_type": "character",
                "setup_params": {"count": 5, "data": {"1": ("b", 1, 2), "2": ("c", 2, 3)}},
                "method_params": {"offset": 1},
                "expected": ("c", 2, 3),
            },
            {
                "id": "next_character_no_current",
                "direction": "next",
                "unit_type": "character",
                "setup_params": {"caret_offset": 5, "data": {}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "next_character_at_end",
                "direction": "next",
                "unit_type": "character",
                "setup_params": {"caret_offset": 4, "count": 5, "data": {"4": ("z", 4, 5)}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "next_word_successful",
                "direction": "next",
                "unit_type": "word",
                "setup_params": {
                    "caret_offset": 0,
                    "count": 15,
                    "word_data": {"0-5": ("first", 0, 5), "6-11": ("second", 6, 11)},
                },
                "method_params": {},
                "expected": ("second", 6, 11),
            },
            {
                "id": "next_word_with_offset",
                "direction": "next",
                "unit_type": "word",
                "setup_params": {
                    "count": 15,
                    "word_data": {"0-5": ("first", 0, 5), "6-11": ("second", 6, 11)},
                },
                "method_params": {"offset": 2},
                "expected": ("second", 6, 11),
            },
            {
                "id": "next_word_no_current",
                "direction": "next",
                "unit_type": "word",
                "setup_params": {"caret_offset": 5, "word_data": {}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "next_word_at_end",
                "direction": "next",
                "unit_type": "word",
                "setup_params": {
                    "caret_offset": 10,
                    "count": 15,
                    "word_data": {"10-15": ("last", 10, 15)},
                },
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "next_line_successful",
                "direction": "next",
                "unit_type": "line",
                "setup_params": {
                    "caret_offset": 0,
                    "count": 25,
                    "line_data": {"0-10": ("First line", 0, 10), "11-20": ("Second line", 11, 20)},
                },
                "method_params": {},
                "expected": ("Second line", 11, 20),
            },
            {
                "id": "next_line_with_offset",
                "direction": "next",
                "unit_type": "line",
                "setup_params": {
                    "count": 25,
                    "line_data": {"0-10": ("First line", 0, 10), "11-20": ("Second line", 11, 20)},
                },
                "method_params": {"offset": 5},
                "expected": ("Second line", 11, 20),
            },
            {
                "id": "next_line_no_current",
                "direction": "next",
                "unit_type": "line",
                "setup_params": {"caret_offset": 5, "line_data": {}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "next_line_at_end",
                "direction": "next",
                "unit_type": "line",
                "setup_params": {
                    "caret_offset": 15,
                    "count": 25,
                    "line_data": {"15-25": ("Last line", 15, 25)},
                },
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "next_sentence_successful",
                "direction": "next",
                "unit_type": "sentence",
                "setup_params": {
                    "caret_offset": 0,
                    "count": 30,
                    "sentence_data": {
                        "0-10": ("First sentence.", 0, 10),
                        "11-25": ("Second sentence.", 11, 25),
                    },
                },
                "method_params": {},
                "expected": ("Second sentence.", 11, 25),
            },
            {
                "id": "next_sentence_with_offset",
                "direction": "next",
                "unit_type": "sentence",
                "setup_params": {
                    "count": 30,
                    "sentence_data": {
                        "0-10": ("First sentence.", 0, 10),
                        "11-25": ("Second sentence.", 11, 25),
                    },
                },
                "method_params": {"offset": 5},
                "expected": ("Second sentence.", 11, 25),
            },
            {
                "id": "next_sentence_no_current",
                "direction": "next",
                "unit_type": "sentence",
                "setup_params": {"caret_offset": 5, "sentence_data": {}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "next_sentence_at_end",
                "direction": "next",
                "unit_type": "sentence",
                "setup_params": {
                    "caret_offset": 20,
                    "count": 30,
                    "sentence_data": {"20-30": ("Last sentence.", 20, 30)},
                },
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "next_paragraph_successful",
                "direction": "next",
                "unit_type": "paragraph",
                "setup_params": {
                    "caret_offset": 0,
                    "count": 40,
                    "paragraph_data": {
                        "0-15": ("First paragraph.", 0, 15),
                        "16-30": ("Second paragraph.", 16, 30),
                    },
                },
                "method_params": {},
                "expected": ("Second paragraph.", 16, 30),
            },
            {
                "id": "next_paragraph_with_offset",
                "direction": "next",
                "unit_type": "paragraph",
                "setup_params": {
                    "count": 40,
                    "paragraph_data": {
                        "0-15": ("First paragraph.", 0, 15),
                        "16-30": ("Second paragraph.", 16, 30),
                    },
                },
                "method_params": {"offset": 8},
                "expected": ("Second paragraph.", 16, 30),
            },
            {
                "id": "next_paragraph_no_current",
                "direction": "next",
                "unit_type": "paragraph",
                "setup_params": {"caret_offset": 5, "paragraph_data": {}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "next_paragraph_at_end",
                "direction": "next",
                "unit_type": "paragraph",
                "setup_params": {
                    "caret_offset": 25,
                    "count": 40,
                    "paragraph_data": {"25-40": ("Last paragraph.", 25, 40)},
                },
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "previous_character_successful",
                "direction": "previous",
                "unit_type": "character",
                "setup_params": {"caret_offset": 2, "data": {"1": ("a", 1, 2), "2": ("b", 2, 3)}},
                "method_params": {},
                "expected": ("a", 1, 2),
            },
            {
                "id": "previous_character_with_offset",
                "direction": "previous",
                "unit_type": "character",
                "setup_params": {"data": {"0": ("a", 0, 1), "1": ("b", 1, 2)}},
                "method_params": {"offset": 1},
                "expected": ("a", 0, 1),
            },
            {
                "id": "previous_character_no_current",
                "direction": "previous",
                "unit_type": "character",
                "setup_params": {"caret_offset": 5, "data": {}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "previous_character_at_start",
                "direction": "previous",
                "unit_type": "character",
                "setup_params": {"caret_offset": 0, "data": {"0": ("a", 0, 1)}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "previous_word_successful",
                "direction": "previous",
                "unit_type": "word",
                "setup_params": {
                    "caret_offset": 8,
                    "word_data": {"0-4": ("first", 0, 4), "5-11": ("second", 5, 11)},
                },
                "method_params": {},
                "expected": ("first", 0, 4),
            },
            {
                "id": "previous_word_with_offset",
                "direction": "previous",
                "unit_type": "word",
                "setup_params": {"word_data": {"0-4": ("first", 0, 4), "5-11": ("second", 5, 11)}},
                "method_params": {"offset": 8},
                "expected": ("first", 0, 4),
            },
            {
                "id": "previous_word_no_current",
                "direction": "previous",
                "unit_type": "word",
                "setup_params": {"caret_offset": 5, "word_data": {}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "previous_word_at_start",
                "direction": "previous",
                "unit_type": "word",
                "setup_params": {"caret_offset": 0, "word_data": {"0-4": ("first", 0, 4)}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "previous_line_successful",
                "direction": "previous",
                "unit_type": "line",
                "setup_params": {
                    "caret_offset": 15,
                    "line_data": {"0-10": ("First line", 0, 10), "11-20": ("Second line", 11, 20)},
                },
                "method_params": {},
                "expected": ("First line", 0, 10),
            },
            {
                "id": "previous_line_with_offset",
                "direction": "previous",
                "unit_type": "line",
                "setup_params": {
                    "line_data": {"0-10": ("First line", 0, 10), "11-20": ("Second line", 11, 20)},
                },
                "method_params": {"offset": 15},
                "expected": ("First line", 0, 10),
            },
            {
                "id": "previous_line_no_current",
                "direction": "previous",
                "unit_type": "line",
                "setup_params": {"caret_offset": 5, "count": 10, "line_data": {}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "previous_line_at_start",
                "direction": "previous",
                "unit_type": "line",
                "setup_params": {"caret_offset": 0, "line_data": {"0-10": ("First line", 0, 10)}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "previous_sentence_successful",
                "direction": "previous",
                "unit_type": "sentence",
                "setup_params": {
                    "caret_offset": 20,
                    "sentence_data": {
                        "0-15": ("First sentence.", 0, 15),
                        "16-30": ("Second sentence.", 16, 30),
                    },
                },
                "method_params": {},
                "expected": ("First sentence.", 0, 15),
            },
            {
                "id": "previous_sentence_with_offset",
                "direction": "previous",
                "unit_type": "sentence",
                "setup_params": {
                    "sentence_data": {
                        "0-15": ("First sentence.", 0, 15),
                        "16-30": ("Second sentence.", 16, 30),
                    },
                },
                "method_params": {"offset": 20},
                "expected": ("First sentence.", 0, 15),
            },
            {
                "id": "previous_sentence_no_current",
                "direction": "previous",
                "unit_type": "sentence",
                "setup_params": {"caret_offset": 5, "sentence_data": {}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "previous_sentence_at_start",
                "direction": "previous",
                "unit_type": "sentence",
                "setup_params": {
                    "caret_offset": 0,
                    "sentence_data": {"0-15": ("First sentence.", 0, 15)},
                },
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "previous_paragraph_successful",
                "direction": "previous",
                "unit_type": "paragraph",
                "setup_params": {
                    "caret_offset": 20,
                    "paragraph_data": {
                        "0-15": ("First paragraph.", 0, 15),
                        "16-30": ("Second paragraph.", 16, 30),
                    },
                },
                "method_params": {},
                "expected": ("First paragraph.", 0, 15),
            },
            {
                "id": "previous_paragraph_with_offset",
                "direction": "previous",
                "unit_type": "paragraph",
                "setup_params": {
                    "paragraph_data": {
                        "0-15": ("First paragraph.", 0, 15),
                        "16-30": ("Second paragraph.", 16, 30),
                    },
                },
                "method_params": {"offset": 20},
                "expected": ("First paragraph.", 0, 15),
            },
            {
                "id": "previous_paragraph_no_current",
                "direction": "previous",
                "unit_type": "paragraph",
                "setup_params": {"caret_offset": 5, "paragraph_data": {}},
                "method_params": {},
                "expected": ("", 0, 0),
            },
            {
                "id": "previous_paragraph_at_start",
                "direction": "previous",
                "unit_type": "paragraph",
                "setup_params": {
                    "caret_offset": 0,
                    "paragraph_data": {"0-15": ("First paragraph.", 0, 15)},
                },
                "method_params": {},
                "expected": ("", 0, 0),
            },
        ],
        ids=lambda case: case["id"],
    )
    def test_get_next_text_unit_scenarios(  # pylint: disable=too-many-branches,too-many-statements
        self,
        test_context: OrcaTestContext,
        case: dict,
    ) -> None:
        """Test AXText.get_next_* and get_previous_* methods with various scenarios."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        setup_params = case["setup_params"]
        method_params = case["method_params"]
        direction = case["direction"]
        unit_type = case["unit_type"]
        expected = case["expected"]

        if "caret_offset" in setup_params:
            test_context.patch_object(
                AXText,
                "get_caret_offset",
                side_effect=lambda obj: setup_params["caret_offset"],
            )
        if "count" in setup_params:
            test_context.patch_object(
                AXText,
                "get_character_count",
                side_effect=lambda obj: setup_params["count"],
            )

        if unit_type == "character":

            def mock_get_character_at_offset(_obj, offset) -> tuple[str, int, int]:
                return setup_params["data"].get(str(offset), ("", 0, 0))

            test_context.patch_object(
                AXText,
                "get_character_at_offset",
                new=mock_get_character_at_offset,
            )

            mock_obj = test_context.Mock(spec=Atspi.Accessible)
            if direction == "next":
                if method_params:
                    result = AXUtilitiesText.get_next_character(mock_obj, **method_params)
                else:
                    result = AXUtilitiesText.get_next_character(mock_obj)
            elif method_params:
                result = AXUtilitiesText.get_previous_character(mock_obj, **method_params)
            else:
                result = AXUtilitiesText.get_previous_character(mock_obj)
        elif unit_type == "word":

            def mock_get_word_at_offset(_obj, offset) -> tuple[str, int, int]:
                for range_key, data in setup_params.get("word_data", {}).items():
                    if "-" in range_key:
                        start, end = map(int, range_key.split("-"))
                        if start <= offset <= end:
                            return data
                return ("", 0, 0)

            test_context.patch_object(AXText, "get_word_at_offset", new=mock_get_word_at_offset)

            mock_obj = test_context.Mock(spec=Atspi.Accessible)
            if direction == "next":
                if method_params:
                    result = AXUtilitiesText.get_next_word(mock_obj, **method_params)
                else:
                    result = AXUtilitiesText.get_next_word(mock_obj)
            elif method_params:
                result = AXUtilitiesText.get_previous_word(mock_obj, **method_params)
            else:
                result = AXUtilitiesText.get_previous_word(mock_obj)
        elif unit_type == "line":

            def mock_get_line_at_offset(_obj, offset) -> tuple[str, int, int]:
                for range_key, data in setup_params.get("line_data", {}).items():
                    if "-" in range_key:
                        start, end = map(int, range_key.split("-"))
                        if start <= offset <= end:
                            return data
                return ("", 0, 0)

            test_context.patch_object(AXText, "get_line_at_offset", new=mock_get_line_at_offset)

            mock_obj = test_context.Mock(spec=Atspi.Accessible)
            if direction == "next":
                if method_params:
                    result = AXUtilitiesText.get_next_line(mock_obj, **method_params)
                else:
                    result = AXUtilitiesText.get_next_line(mock_obj)
            elif method_params:
                result = AXUtilitiesText.get_previous_line(mock_obj, **method_params)
            else:
                result = AXUtilitiesText.get_previous_line(mock_obj)
        elif unit_type == "sentence":

            def mock_get_sentence_at_offset(_obj, offset) -> tuple[str, int, int]:
                for range_key, data in setup_params.get("sentence_data", {}).items():
                    if "-" in range_key:
                        start, end = map(int, range_key.split("-"))
                        if start <= offset <= end:
                            return data
                return ("", 0, 0)

            test_context.patch_object(
                AXText,
                "get_sentence_at_offset",
                new=mock_get_sentence_at_offset,
            )

            mock_obj = test_context.Mock(spec=Atspi.Accessible)
            if direction == "next":
                if method_params:
                    result = AXUtilitiesText.get_next_sentence(mock_obj, **method_params)
                else:
                    result = AXUtilitiesText.get_next_sentence(mock_obj)
            elif method_params:
                result = AXUtilitiesText.get_previous_sentence(mock_obj, **method_params)
            else:
                result = AXUtilitiesText.get_previous_sentence(mock_obj)
        elif unit_type == "paragraph":

            def mock_get_paragraph_at_offset(_obj, offset) -> tuple[str, int, int]:
                for range_key, data in setup_params.get("paragraph_data", {}).items():
                    if "-" in range_key:
                        start, end = map(int, range_key.split("-"))
                        if start <= offset <= end:
                            return data
                return ("", 0, 0)

            test_context.patch_object(
                AXText,
                "get_paragraph_at_offset",
                new=mock_get_paragraph_at_offset,
            )

            mock_obj = test_context.Mock(spec=Atspi.Accessible)
            if direction == "next":
                if method_params:
                    result = AXUtilitiesText.get_next_paragraph(mock_obj, **method_params)
                else:
                    result = AXUtilitiesText.get_next_paragraph(mock_obj)
            elif method_params:
                result = AXUtilitiesText.get_previous_paragraph(mock_obj, **method_params)
            else:
                result = AXUtilitiesText.get_previous_paragraph(mock_obj)
        else:
            result = ("", 0, 0)

        assert result == expected

    def test_get_character_at_offset_with_empty_text(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_character_at_offset when text is empty."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        test_context.patch("gi.repository.Atspi.Text.get_character_count", return_value=0)
        result = AXText.get_character_at_offset(test_context.Mock(spec=Atspi.Accessible), 0)
        assert result == ("", 0, 0)

    def test_get_character_at_offset_with_none_offset(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_character_at_offset when offset is None."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        mock_obj = test_context.Mock(spec=Atspi.Accessible)
        test_context.patch("gi.repository.Atspi.Text.get_character_count", return_value=10)
        test_context.patch_object(AXText, "get_caret_offset", return_value=5)

        mock_result = test_context.Mock()
        mock_result.content = "a"
        mock_result.start_offset = 5
        mock_result.end_offset = 6
        test_context.patch(
            "gi.repository.Atspi.Text.get_string_at_offset",
            side_effect=lambda obj, offset, granularity: mock_result,
        )

        result = AXText.get_character_at_offset(mock_obj, None)
        assert result == ("a", 5, 6)

    def test_get_character_at_offset_with_glib_error(self, test_context: OrcaTestContext) -> None:
        """Test AXText.get_character_at_offset when GLib.GError occurs."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        def raise_glib_error(_obj, _offset, _granularity):
            raise GLib.GError("Test error")

        test_context.patch("gi.repository.Atspi.Text.get_character_count", return_value=10)
        test_context.patch("gi.repository.Atspi.Text.get_string_at_offset", new=raise_glib_error)

        result = AXText.get_character_at_offset(test_context.Mock(spec=Atspi.Accessible), 5)
        assert result == ("", 0, 0)

    def test_get_previous_line_at_end_with_newline_sets_correct_start(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.get_previous_line sets start when fallback ends with newline."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        mock_obj = test_context.Mock(spec=Atspi.Accessible)

        # Mock get_character_count to return 12 (end of text)
        test_context.patch_object(AXText, "get_character_count", return_value=12)

        # Mock get_line_at_offset for the sequence
        def mock_get_line_at_offset(_obj, offset):
            if offset == 12:  # At end of text - empty line
                return ("", 12, 12)
            if offset == 11:  # One character back - line ending with newline
                return ("Hello World\n", 0, 12)
            if offset == 10:  # Previous line search from (offset-1)-1 = 10
                return ("Previous Line", 0, 11)
            return ("", 0, 0)

        test_context.patch_object(AXText, "get_line_at_offset", new=mock_get_line_at_offset)

        # Test: when at end with empty line and fallback has newline
        result = AXUtilitiesText.get_previous_line(mock_obj, 12)

        # The current line becomes "Hello World\n" with start=11
        assert result == ("Previous Line", 0, 11)

    def test_get_previous_line_at_end_of_text_fallback_to_normal_behavior(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.get_previous_line at end when fallback lacks newline."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        mock_obj = test_context.Mock(spec=Atspi.Accessible)

        # Mock get_character_count to return 11 (end of text)
        test_context.patch_object(AXText, "get_character_count", return_value=11)

        # Mock get_line_at_offset scenarios
        def mock_get_line_at_offset(_obj, offset):
            if offset == 11:  # At end of text
                return ("", 11, 11)
            if offset == 10:  # One character back - no newline
                return ("Hello World", 5, 15)  # start > 0 so it continues
            if offset == 4:  # Previous line lookup from start-1 = 4
                return ("Previous Line", 0, 5)
            return ("", 0, 0)

        test_context.patch_object(AXText, "get_line_at_offset", new=mock_get_line_at_offset)

        # Test: should use normal behavior when fallback line doesn't end with newline
        result = AXUtilitiesText.get_previous_line(mock_obj, 11)
        assert result == ("Previous Line", 0, 5)

    def test_get_previous_line_empty_current_line_returns_empty(
        self,
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText.get_previous_line returns empty when current line is empty."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText
        from orca.ax_utilities_text import AXUtilitiesText

        mock_obj = test_context.Mock(spec=Atspi.Accessible)

        # Mock get_character_count to return 5 (not at end)
        test_context.patch_object(AXText, "get_character_count", return_value=5)

        # Mock get_line_at_offset to return empty line not at end of text
        def mock_get_line_at_offset(_obj, _offset):
            return ("", 3, 3)

        test_context.patch_object(AXText, "get_line_at_offset", new=mock_get_line_at_offset)

        # Test: when current_line is empty (and not at end), should return empty
        result = AXUtilitiesText.get_previous_line(mock_obj, 3)
        assert result == ("", 0, 0)

    @pytest.mark.parametrize(
        "text, expected_boundaries",
        [
            pytest.param("", [], id="empty_string"),
            pytest.param("Hello", [0, 5], id="no_sentence_ending"),
            pytest.param("Hello.", [0, 6], id="single_sentence"),
            pytest.param("Hello. World.", [0, 7, 13], id="two_sentences"),
            pytest.param("Hello! World?", [0, 7, 13], id="exclamation_and_question"),
            pytest.param("Hello... World.", [0, 9, 15], id="ellipsis"),
            pytest.param("Hello.  World.", [0, 8, 14], id="double_space_after_period"),
        ],
        ids=lambda case: case if isinstance(case, str) else None,
    )
    def test_find_sentence_boundaries_basic(
        self,
        test_context: OrcaTestContext,
        text: str,
        expected_boundaries: list[int],
    ) -> None:
        """Test AXText._find_sentence_boundaries with basic text."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        result = AXText._find_sentence_boundaries(text)
        assert result == expected_boundaries

    @pytest.mark.parametrize(
        "text, expected_boundaries",
        [
            pytest.param("Hello.\ufffc", [0, 6, 7], id="sentence_ending_before_fffc"),
            pytest.param("Hello. \ufffc", [0, 7, 8], id="sentence_ending_space_fffc"),
            pytest.param(
                "Test 1. Test 2. \ufffc Test 3.",
                [0, 8, 16, 25],
                id="fffc_between_sentences",
            ),
            pytest.param(
                "Test 1. Test 2. \ufffcTest 4.",
                [0, 8, 16, 24],
                id="nested_element_fffc_no_space_after",
            ),
        ],
        ids=lambda case: case if isinstance(case, str) else None,
    )
    def test_find_sentence_boundaries_with_embedded_objects(
        self,
        test_context: OrcaTestContext,
        text: str,
        expected_boundaries: list[int],
    ) -> None:
        """Test AXText._find_sentence_boundaries with embedded object characters (FFFC).

        Embedded object characters represent child accessible objects in the text.
        The boundary should be set AT the FFFC position so that the child object
        can be properly traversed, rather than skipping over it.
        """

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        result = AXText._find_sentence_boundaries(text)
        assert result == expected_boundaries


_GRAPHEME_US_FLAG = "\U0001f1fa\U0001f1f8"
_GRAPHEME_IT_FLAG = "\U0001f1ee\U0001f1f9"
_GRAPHEME_FAMILY = "\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466"
_GRAPHEME_US_FLAG_FEFF = "\U0001f1fa\ufeff\U0001f1f8\ufeff"
_GRAPHEME_FAMILY_FEFF = (
    "\U0001f468\ufeff\u200d\U0001f469\ufeff\u200d\U0001f467\ufeff\u200d\U0001f466\ufeff"
)


@pytest.mark.unit
class TestAXTextWholeCharacters:
    """Test AXText whole-character offset adjustment."""

    def _setup_dependencies(self, test_context: OrcaTestContext) -> dict[str, MagicMock]:
        """Set up mocks for ax_text dependencies."""

        additional_modules = [
            "locale",
            "orca.colornames",
            "orca.ax_utilities_role",
            "orca.ax_utilities_state",
        ]
        return test_context.setup_shared_dependencies(additional_modules)

    @pytest.mark.parametrize(
        "text, start, end, expected",
        [
            pytest.param("from " + _GRAPHEME_US_FLAG + " to", 5, 6, (5, 7), id="flag_first"),
            pytest.param("from " + _GRAPHEME_US_FLAG + " to", 6, 7, (5, 7), id="flag_second"),
            pytest.param("a " + _GRAPHEME_FAMILY + " b", 2, 3, (2, 9), id="family_first"),
            pytest.param("a " + _GRAPHEME_FAMILY + " b", 6, 7, (2, 9), id="family_middle"),
            pytest.param("a " + _GRAPHEME_FAMILY_FEFF + " b", 2, 3, (2, 13), id="family_feff"),
            pytest.param(
                "from " + _GRAPHEME_US_FLAG_FEFF + " to", 5, 6, (5, 9), id="gecko_flag_feff"
            ),
            pytest.param(
                _GRAPHEME_US_FLAG + _GRAPHEME_IT_FLAG, 2, 3, (2, 4), id="adjacent_flags_separate"
            ),
            pytest.param("hello", 1, 3, (1, 3), id="plain_unchanged"),
            pytest.param("\u2764\ufe0fx", 0, 1, (0, 2), id="variation_selector"),
            pytest.param("\U0001f44d\U0001f3fd x", 0, 1, (0, 2), id="skin_tone"),
        ],
    )
    def test_adjust_to_whole_characters(
        self,
        text: str,
        start: int,
        end: int,
        expected: tuple[int, int],
        test_context: OrcaTestContext,
    ) -> None:
        """Test AXText._adjust_to_whole_characters extends spans to whole characters."""

        self._setup_dependencies(test_context)
        from orca.ax_text import AXText

        assert AXText._adjust_to_whole_characters(text, start, end) == expected
