0

Fixed AXPosition's comparison operators in their handling of embedded objects and affinity

This patch needs to land before we would be able to merge BrowserAccessibilityPosition
with AXNodePosition.

Example AX tree referred to from below:
++kRootWebArea "<embedded><embedded>"
++++kParagraph1 "hello"
++++kParagraph2 "world"

1. Embedded objects:
According to the IAccessible2 Spec, a text position inside an embedded object is equivalent to an ancestor
position after the embedded object, unless the former is at its start.

A) In the middle of paragraph1.
TextPosition anchor=kParagraph1 text_offset=3 annotated_text=hel<l>o

B) Ancestor equivalent position to (A).
TextPosition anchor=kRootWebArea text_offset=1 affinity=upstream

Comparing (A) to (B) should return that they are equivalent.

The reason behind this decision is that there would always be some loss of information when
moving to an ancestor equivalent position from within an embedded object, and there had to be a default behavior
as to where that ancestor position would be placed for purposes of comparing caret positions.
If the caret position is after the embedded object character in the embedded object's parent, then
the caret is either inside or after the embedded object, and the AT needs to query the embedded object to find out a more
exact position.

Similarly, AXPosition needs to have a default way of comparing a text position with a nancestor position
in order to be able to compare caret and selection endpoints for Windows (IAccessible2) and Linux (ATK) AT clients.

2. Affinity:
Two text positions with different affinities were compared correctly.
However, in some cases, comparing a tree to a text position needs to have the same treatment.
If everything else is equivalent except affinities, then an upstream affinity should be less than
a downstream affinity.

A) Right before the second paragraph
TextPosition anchor=kRootWebArea text_offset=1 affinity=downstream

B) After the first paragraph's text (an "after text position").
TreePosition anchor=kParagraph1 child_id=0
(child_index=0 signifies "after text".)

Comparing (A) to (B) should not return that they are equal.

3. Re-enabled tests that had to be disabled in order to land
https://crrev.com/c/2665948
because of bugs in `AXPosition::CompareTo` and `AXPosition::SlowCompareTo`,
bugs that have been fixed by this patch.

4. Unexpectedly, this patch has also fixed a known bug (1039528) in one of our
Windows unittests.

R=dmazzoni@chromium.org, aleventhal@chromium.org, kschmi@microsoft.com

AX-Relnotes: n/a.
Change-Id: Ic075e18edaa97d0ece1bf795cc31a4b159d431ab
Bug: 1049261, 1039528
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2658639
Commit-Queue: Nektarios Paisios <nektar@chromium.org>
Reviewed-by: Dominic Mazzoni <dmazzoni@chromium.org>
Cr-Commit-Position: refs/heads/master@{#852568}
This commit is contained in:
Nektarios Paisios
2021-02-10 10:28:38 +00:00
committed by Chromium LUCI CQ
parent 8190a2e8c4
commit 0fedd71fce
8 changed files with 579 additions and 177 deletions

@@ -234,9 +234,8 @@ IN_PROC_BROWSER_TEST_F(AccessibilityAuraLinuxBrowserTest,
host_view_parent));
}
IN_PROC_BROWSER_TEST_F(
AccessibilityAuraLinuxBrowserTest,
DISABLED_TestTextAtOffsetWithBoundaryCharacterAndEmbeddedObject) {
IN_PROC_BROWSER_TEST_F(AccessibilityAuraLinuxBrowserTest,
TestTextAtOffsetWithBoundaryCharacterAndEmbeddedObject) {
LoadInitialAccessibilityTreeFromHtml(R"HTML(<!DOCTYPE html>
<div contenteditable>
Before<img alt="image">after.

@@ -3716,9 +3716,8 @@ IN_PROC_BROWSER_TEST_F(AccessibilityWinBrowserTest,
}
}
IN_PROC_BROWSER_TEST_F(
AccessibilityWinBrowserTest,
DISABLED_TestTextAtOffsetWithBoundaryCharacterAndEmbeddedObject) {
IN_PROC_BROWSER_TEST_F(AccessibilityWinBrowserTest,
TestTextAtOffsetWithBoundaryCharacterAndEmbeddedObject) {
LoadInitialAccessibilityTreeFromHtml(R"HTML(<!DOCTYPE html>
<div contenteditable>
Before<img alt="image">after.

@@ -31,6 +31,16 @@ BrowserAccessibilityPosition::Clone() const {
base::string16 BrowserAccessibilityPosition::GetText() const {
if (IsNullPosition())
return {};
// Special case, if a position's anchor node has only ignored descendants,
// i.e., it appears to be empty to assistive software, on some platforms we
// need to still treat it as a character and a word boundary. We achieve this
// by adding an embedded object character in the text representation used by
// this class, but we don't expose that character to assistive software that
// tries to retrieve the node's inner text.
if (IsEmptyObjectReplacedByCharacter())
return ui::AXNode::kEmbeddedCharacter;
DCHECK(GetAnchor());
return GetAnchor()->GetText();
}

@@ -1479,16 +1479,11 @@ TEST_F(BrowserAccessibilityWinTest, TextBoundariesOnlyEmbeddedObjectsNoCrash) {
ASSERT_NE(nullptr, menu_accessible_com);
ASSERT_EQ(ax::mojom::Role::kMenu, menu_accessible_com->GetData().role);
// TODO(crbug.com/1039528): This should not have 2 embedded object characters.
{
const std::array<base::char16, 2> pieces = {
ui::AXPlatformNodeBase::kEmbeddedCharacter,
ui::AXPlatformNodeBase::kEmbeddedCharacter};
const base::string16 expect(pieces.cbegin(), pieces.cend());
EXPECT_IA2_TEXT_AT_OFFSET(menu_accessible_com, 0, IA2_TEXT_BOUNDARY_CHAR,
/*expected_hr=*/S_OK, /*start=*/0, /*end=*/2,
/*text=*/base::as_wcstr(expect));
}
EXPECT_IA2_TEXT_AT_OFFSET(
menu_accessible_com, 0, IA2_TEXT_BOUNDARY_CHAR,
/*expected_hr=*/S_OK, /*start=*/0, /*end=*/1,
/*text=*/
base::string16{ui::AXPlatformNodeBase::kEmbeddedCharacter}.c_str());
}
TEST_F(BrowserAccessibilityWinTest,
@@ -1640,6 +1635,8 @@ TEST_F(BrowserAccessibilityWinTest,
ASSERT_NE(nullptr, static_text_3_com);
ASSERT_EQ(ax::mojom::Role::kStaticText, static_text_3_com->GetData().role);
// [obj] stands for the embedded object replacement character \xFFFC.
// L"<b>efore" [obj] [obj] L"after" L"tail"
EXPECT_IA2_TEXT_AT_OFFSET(body_accessible_com, 0, IA2_TEXT_BOUNDARY_CHAR,
/*expected_hr=*/S_OK, /*start=*/0, /*end=*/1,
@@ -1656,29 +1653,18 @@ TEST_F(BrowserAccessibilityWinTest,
/*text=*/L"e");
// L"before" <[obj]> [obj] L"after" L"tail"
// TODO(crbug.com/1039528): This should not include multiple characters.
{
const std::array<base::char16, 3> pieces = {
ui::AXPlatformNodeBase::kEmbeddedCharacter,
ui::AXPlatformNodeBase::kEmbeddedCharacter, L'a'};
const base::string16 expect(pieces.cbegin(), pieces.cend());
EXPECT_IA2_TEXT_AT_OFFSET(body_accessible_com, 6, IA2_TEXT_BOUNDARY_CHAR,
/*expected_hr=*/S_OK, /*start=*/6, /*end=*/9,
/*text=*/
base::as_wcstr(expect));
}
EXPECT_IA2_TEXT_AT_OFFSET(
body_accessible_com, 6, IA2_TEXT_BOUNDARY_CHAR,
/*expected_hr=*/S_OK, /*start=*/6, /*end=*/7,
/*text=*/
base::string16{ui::AXPlatformNodeBase::kEmbeddedCharacter}.c_str());
// L"before" [obj] <[obj]> L"after" L"tail"
// TODO(crbug.com/1039528): This should not include multiple characters.
{
const std::array<base::char16, 2> pieces = {
ui::AXPlatformNodeBase::kEmbeddedCharacter, L'a'};
const base::string16 expect(pieces.cbegin(), pieces.cend());
EXPECT_IA2_TEXT_AT_OFFSET(body_accessible_com, 7, IA2_TEXT_BOUNDARY_CHAR,
/*expected_hr=*/S_OK, /*start=*/7, /*end=*/9,
/*text=*/
base::as_wcstr(expect));
}
EXPECT_IA2_TEXT_AT_OFFSET(
body_accessible_com, 7, IA2_TEXT_BOUNDARY_CHAR,
/*expected_hr=*/S_OK, /*start=*/7, /*end=*/8,
/*text=*/
base::string16{ui::AXPlatformNodeBase::kEmbeddedCharacter}.c_str());
// L"before" [obj] [obj] L"<a>fter" L"tail"
EXPECT_IA2_TEXT_AT_OFFSET(body_accessible_com, 8, IA2_TEXT_BOUNDARY_CHAR,
@@ -1810,22 +1796,18 @@ TEST_F(BrowserAccessibilityWinTest,
/*text=*/nullptr);
// L"befor<e>" [obj] <[obj]> L"after" L"tail"
// TODO(crbug.com/1039528): This should not include multiple characters.
{
const std::array<base::char16, 3> pieces = {
ui::AXPlatformNodeBase::kEmbeddedCharacter,
ui::AXPlatformNodeBase::kEmbeddedCharacter, L'a'};
const base::string16 expect(pieces.cbegin(), pieces.cend());
EXPECT_IA2_TEXT_AFTER_OFFSET(body_accessible_com, 5, IA2_TEXT_BOUNDARY_CHAR,
/*expected_hr=*/S_OK, /*start=*/6, /*end=*/9,
/*text=*/base::as_wcstr(expect));
}
EXPECT_IA2_TEXT_AFTER_OFFSET(
body_accessible_com, 5, IA2_TEXT_BOUNDARY_CHAR,
/*expected_hr=*/S_OK, /*start=*/6, /*end=*/7,
/*text=*/
base::string16{ui::AXPlatformNodeBase::kEmbeddedCharacter}.c_str());
// L"before" <[obj]> [obj] L"after" L"tail"
// TODO(crbug.com/1039528): This should probably not skip over L"a"
EXPECT_IA2_TEXT_AFTER_OFFSET(body_accessible_com, 6, IA2_TEXT_BOUNDARY_CHAR,
/*expected_hr=*/S_OK, /*start=*/9, /*end=*/10,
/*text=*/L"f");
EXPECT_IA2_TEXT_AFTER_OFFSET(
body_accessible_com, 6, IA2_TEXT_BOUNDARY_CHAR,
/*expected_hr=*/S_OK, /*start=*/7, /*end=*/8,
/*text=*/
base::string16{ui::AXPlatformNodeBase::kEmbeddedCharacter}.c_str());
// <[obj]>
EXPECT_IA2_TEXT_AFTER_OFFSET(menu_1_accessible_com, 0, IA2_TEXT_BOUNDARY_CHAR,

@@ -3713,6 +3713,7 @@ TEST_F(AXPositionTest, AsLeafTextPositionWithTextPositionAndEmbeddedObject) {
inline_box.id = 6;
root.role = ax::mojom::Role::kRootWebArea;
root.AddBoolAttribute(ax::mojom::BoolAttribute::kIsLineBreakingObject, true);
root.child_ids = {image.id, paragraph.id};
image.role = ax::mojom::Role::kImage;
@@ -3722,6 +3723,8 @@ TEST_F(AXPositionTest, AsLeafTextPositionWithTextPositionAndEmbeddedObject) {
image.SetNameFrom(ax::mojom::NameFrom::kAttribute);
paragraph.role = ax::mojom::Role::kParagraph;
paragraph.AddBoolAttribute(ax::mojom::BoolAttribute::kIsLineBreakingObject,
true);
paragraph.child_ids = {link.id};
link.role = ax::mojom::Role::kLink;
@@ -8695,13 +8698,12 @@ TEST_F(AXPositionTest, OperatorEquals) {
ASSERT_TRUE(text_position2->IsTextPosition());
EXPECT_EQ(*text_position1, *text_position2);
// Affinities should not matter.
text_position2 = AXNodePosition::CreateTextPosition(
GetTreeID(), inline_box1_.id, 0 /* text_offset */,
ax::mojom::TextAffinity::kUpstream);
ASSERT_NE(nullptr, text_position2);
ASSERT_TRUE(text_position2->IsTextPosition());
EXPECT_EQ(*text_position1, *text_position2);
EXPECT_GT(*text_position1, *text_position2);
// Text offsets should match.
text_position1 = AXNodePosition::CreateTextPosition(
@@ -8810,6 +8812,317 @@ TEST_F(AXPositionTest, OperatorEqualsSameTextOffsetDifferentAnchorIdLeaf) {
ASSERT_TRUE(*text_position_two == *text_position_one);
}
TEST_F(AXPositionTest, OperatorsTreePositionsAroundEmbeddedCharacter) {
g_ax_embedded_object_behavior = AXEmbeddedObjectBehavior::kExposeCharacter;
// ++1 kRootWebArea "<embedded_object><embedded_object>"
// ++++2 kParagraph "<embedded_object>"
// ++++++3 kLink "Hello"
// ++++++++4 kStaticText "Hello"
// ++++++++++5 kInlineTextBox "Hello"
// ++++6 kParagraph "World"
// ++++++7 kStaticText "World"
// ++++++++8 kInlineTextBox "World"
AXNodeData root_1;
AXNodeData paragraph_2;
AXNodeData link_3;
AXNodeData static_text_4;
AXNodeData inline_box_5;
AXNodeData paragraph_6;
AXNodeData static_text_7;
AXNodeData inline_box_8;
root_1.id = 1;
paragraph_2.id = 2;
link_3.id = 3;
static_text_4.id = 4;
inline_box_5.id = 5;
paragraph_6.id = 6;
static_text_7.id = 7;
inline_box_8.id = 8;
root_1.role = ax::mojom::Role::kRootWebArea;
root_1.AddBoolAttribute(ax::mojom::BoolAttribute::kIsLineBreakingObject,
true);
root_1.child_ids = {paragraph_2.id, paragraph_6.id};
paragraph_2.role = ax::mojom::Role::kParagraph;
paragraph_2.AddBoolAttribute(ax::mojom::BoolAttribute::kIsLineBreakingObject,
true);
paragraph_2.child_ids = {link_3.id};
link_3.role = ax::mojom::Role::kLink;
link_3.AddState(ax::mojom::State::kLinked);
link_3.child_ids = {static_text_4.id};
static_text_4.role = ax::mojom::Role::kStaticText;
static_text_4.SetName("Hello");
static_text_4.child_ids = {inline_box_5.id};
inline_box_5.role = ax::mojom::Role::kInlineTextBox;
inline_box_5.SetName("Hello");
paragraph_6.role = ax::mojom::Role::kParagraph;
paragraph_6.AddBoolAttribute(ax::mojom::BoolAttribute::kIsLineBreakingObject,
true);
paragraph_6.child_ids = {static_text_7.id};
static_text_7.role = ax::mojom::Role::kStaticText;
static_text_7.SetName("World");
static_text_7.child_ids = {inline_box_8.id};
inline_box_8.role = ax::mojom::Role::kInlineTextBox;
inline_box_8.SetName("World");
SetTree(
CreateAXTree({root_1, paragraph_2, link_3, static_text_4, inline_box_5,
paragraph_6, static_text_7, inline_box_8}));
TestPositionType before_root_1 = AXNodePosition::CreateTreePosition(
GetTreeID(), root_1.id, 0 /* child_index */);
ASSERT_NE(nullptr, before_root_1);
TestPositionType middle_root_1 = AXNodePosition::CreateTreePosition(
GetTreeID(), root_1.id, 1 /* child_index */);
ASSERT_NE(nullptr, middle_root_1);
TestPositionType after_root_1 = AXNodePosition::CreateTreePosition(
GetTreeID(), root_1.id, 2 /* child_index */);
ASSERT_NE(nullptr, after_root_1);
TestPositionType before_paragraph_2 = AXNodePosition::CreateTreePosition(
GetTreeID(), paragraph_2.id, 0 /* child_index */);
ASSERT_NE(nullptr, before_paragraph_2);
TestPositionType after_paragraph_2 = AXNodePosition::CreateTreePosition(
GetTreeID(), paragraph_2.id, 1 /* child_index */);
ASSERT_NE(nullptr, after_paragraph_2);
TestPositionType before_paragraph_6 = AXNodePosition::CreateTreePosition(
GetTreeID(), paragraph_6.id, 0 /* child_index */);
ASSERT_NE(nullptr, before_paragraph_6);
TestPositionType after_paragraph_6 = AXNodePosition::CreateTreePosition(
GetTreeID(), paragraph_6.id, 1 /* child_index */);
ASSERT_NE(nullptr, before_paragraph_6);
TestPositionType before_inline_box_5 = AXNodePosition::CreateTreePosition(
GetTreeID(), inline_box_5.id,
AXNodePosition::BEFORE_TEXT /* child_index */);
ASSERT_NE(nullptr, before_inline_box_5);
TestPositionType after_inline_box_5 = AXNodePosition::CreateTreePosition(
GetTreeID(), inline_box_5.id, 0 /* child_index */);
ASSERT_NE(nullptr, after_inline_box_5);
TestPositionType before_inline_box_8 = AXNodePosition::CreateTreePosition(
GetTreeID(), inline_box_8.id,
AXNodePosition::BEFORE_TEXT /* child_index */);
ASSERT_NE(nullptr, before_inline_box_8);
TestPositionType after_inline_box_8 = AXNodePosition::CreateTreePosition(
GetTreeID(), inline_box_8.id, 0 /* child_index */);
ASSERT_NE(nullptr, after_inline_box_8);
EXPECT_EQ(*before_root_1, *before_paragraph_2);
EXPECT_EQ(*before_paragraph_2, *before_root_1);
EXPECT_EQ(*before_root_1, *before_inline_box_5);
EXPECT_EQ(*before_inline_box_5, *before_root_1);
EXPECT_LT(*before_root_1, *middle_root_1);
EXPECT_GT(*before_paragraph_6, *before_inline_box_5);
EXPECT_LT(*before_paragraph_2, *before_inline_box_8);
EXPECT_EQ(*middle_root_1, *before_paragraph_6);
EXPECT_EQ(*before_paragraph_6, *middle_root_1);
EXPECT_EQ(*middle_root_1, *before_inline_box_8);
EXPECT_EQ(*before_inline_box_8, *middle_root_1);
// Since tree positions do not have affinity, all of the following positions
// should be equivalent.
EXPECT_EQ(*middle_root_1, *after_paragraph_2);
EXPECT_EQ(*after_paragraph_2, *middle_root_1);
EXPECT_EQ(*middle_root_1, *after_inline_box_5);
EXPECT_EQ(*after_inline_box_5, *middle_root_1);
EXPECT_EQ(*after_root_1, *after_paragraph_6);
EXPECT_EQ(*after_paragraph_6, *after_root_1);
EXPECT_EQ(*after_root_1, *after_inline_box_8);
EXPECT_EQ(*after_inline_box_8, *after_root_1);
}
TEST_F(AXPositionTest, OperatorsTextPositionsAroundEmbeddedCharacter) {
g_ax_embedded_object_behavior = AXEmbeddedObjectBehavior::kExposeCharacter;
// ++1 kRootWebArea "<embedded_object><embedded_object>"
// ++++2 kParagraph "<embedded_object>"
// ++++++3 kLink "Hello"
// ++++++++4 kStaticText "Hello"
// ++++++++++5 kInlineTextBox "Hello"
// ++++6 kParagraph "World"
// ++++++7 kStaticText "World"
// ++++++++8 kInlineTextBox "World"
AXNodeData root_1;
AXNodeData paragraph_2;
AXNodeData link_3;
AXNodeData static_text_4;
AXNodeData inline_box_5;
AXNodeData paragraph_6;
AXNodeData static_text_7;
AXNodeData inline_box_8;
root_1.id = 1;
paragraph_2.id = 2;
link_3.id = 3;
static_text_4.id = 4;
inline_box_5.id = 5;
paragraph_6.id = 6;
static_text_7.id = 7;
inline_box_8.id = 8;
root_1.role = ax::mojom::Role::kRootWebArea;
root_1.AddBoolAttribute(ax::mojom::BoolAttribute::kIsLineBreakingObject,
true);
root_1.child_ids = {paragraph_2.id, paragraph_6.id};
paragraph_2.role = ax::mojom::Role::kParagraph;
paragraph_2.AddBoolAttribute(ax::mojom::BoolAttribute::kIsLineBreakingObject,
true);
paragraph_2.child_ids = {link_3.id};
link_3.role = ax::mojom::Role::kLink;
link_3.AddState(ax::mojom::State::kLinked);
link_3.child_ids = {static_text_4.id};
static_text_4.role = ax::mojom::Role::kStaticText;
static_text_4.SetName("Hello");
static_text_4.child_ids = {inline_box_5.id};
inline_box_5.role = ax::mojom::Role::kInlineTextBox;
inline_box_5.SetName("Hello");
paragraph_6.role = ax::mojom::Role::kParagraph;
paragraph_6.AddBoolAttribute(ax::mojom::BoolAttribute::kIsLineBreakingObject,
true);
paragraph_6.child_ids = {static_text_7.id};
static_text_7.role = ax::mojom::Role::kStaticText;
static_text_7.SetName("World");
static_text_7.child_ids = {inline_box_8.id};
inline_box_8.role = ax::mojom::Role::kInlineTextBox;
inline_box_8.SetName("World");
SetTree(
CreateAXTree({root_1, paragraph_2, link_3, static_text_4, inline_box_5,
paragraph_6, static_text_7, inline_box_8}));
TestPositionType before_root_1 = AXNodePosition::CreateTextPosition(
GetTreeID(), root_1.id, 0 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, before_root_1);
TestPositionType middle_root_1 = AXNodePosition::CreateTextPosition(
GetTreeID(), root_1.id, 1 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, middle_root_1);
TestPositionType middle_root_1_upstream = AXNodePosition::CreateTextPosition(
GetTreeID(), root_1.id, 1 /* text_offset */,
ax::mojom::TextAffinity::kUpstream);
ASSERT_NE(nullptr, middle_root_1_upstream);
TestPositionType after_root_1 = AXNodePosition::CreateTextPosition(
GetTreeID(), root_1.id, 2 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, after_root_1);
TestPositionType before_paragraph_2 = AXNodePosition::CreateTextPosition(
GetTreeID(), paragraph_2.id, 0 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, before_paragraph_2);
// The first paragraph has a link inside it, so it will only expose a single
// "embedded object replacement character".
TestPositionType after_paragraph_2 = AXNodePosition::CreateTextPosition(
GetTreeID(), paragraph_2.id, 1 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, after_paragraph_2);
TestPositionType before_paragraph_6 = AXNodePosition::CreateTextPosition(
GetTreeID(), paragraph_6.id, 0 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, before_paragraph_6);
// The second paragraph contains "World".
TestPositionType after_paragraph_6 = AXNodePosition::CreateTextPosition(
GetTreeID(), paragraph_6.id, 5 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, before_paragraph_6);
TestPositionType before_inline_box_5 = AXNodePosition::CreateTextPosition(
GetTreeID(), inline_box_5.id, 0 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, before_inline_box_5);
TestPositionType middle_inline_box_5 = AXNodePosition::CreateTextPosition(
GetTreeID(), inline_box_5.id, 1 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, middle_inline_box_5);
// "Hello".
TestPositionType after_inline_box_5 = AXNodePosition::CreateTextPosition(
GetTreeID(), inline_box_5.id, 5 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, after_inline_box_5);
TestPositionType before_inline_box_8 = AXNodePosition::CreateTextPosition(
GetTreeID(), inline_box_8.id, 0 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, before_inline_box_8);
TestPositionType middle_inline_box_8 = AXNodePosition::CreateTextPosition(
GetTreeID(), inline_box_8.id, 1 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, middle_inline_box_8);
// "World".
TestPositionType after_inline_box_8 = AXNodePosition::CreateTextPosition(
GetTreeID(), inline_box_8.id, 5 /* text_offset */,
ax::mojom::TextAffinity::kDownstream);
ASSERT_NE(nullptr, after_inline_box_8);
EXPECT_EQ(*before_root_1, *before_paragraph_2);
EXPECT_EQ(*before_paragraph_2, *before_root_1);
EXPECT_EQ(*before_root_1, *before_inline_box_5);
EXPECT_EQ(*before_inline_box_5, *before_root_1);
EXPECT_LT(*before_root_1, *middle_root_1);
EXPECT_GT(*before_paragraph_6, *before_inline_box_5);
EXPECT_LT(*before_paragraph_2, *before_inline_box_8);
EXPECT_EQ(*middle_root_1, *before_paragraph_6);
EXPECT_EQ(*before_paragraph_6, *middle_root_1);
EXPECT_EQ(*middle_root_1, *before_inline_box_8);
EXPECT_EQ(*before_inline_box_8, *middle_root_1);
EXPECT_GT(*middle_root_1, *after_paragraph_2);
EXPECT_LT(*after_paragraph_2, *middle_root_1);
EXPECT_GT(*middle_root_1, *after_inline_box_5);
EXPECT_LT(*after_inline_box_5, *middle_root_1);
// An upstream affinity on the root before the second paragraph attaches the
// position to the end of the previous line, i.e. moves it to the end of the
// first paragraph.
EXPECT_LT(*middle_root_1_upstream, *middle_root_1);
EXPECT_EQ(*middle_root_1_upstream, *after_paragraph_2);
EXPECT_EQ(*after_paragraph_2, *middle_root_1_upstream);
EXPECT_EQ(*middle_root_1_upstream, *after_inline_box_5);
EXPECT_EQ(*after_inline_box_5, *middle_root_1_upstream);
// According to the IAccessible2 Spec, a position inside an embedded object
// should be equivalent to a position right after it, if the former is not at
// the object's start.
EXPECT_EQ(*middle_root_1_upstream, *middle_inline_box_5);
EXPECT_EQ(*middle_inline_box_5, *middle_root_1_upstream);
EXPECT_EQ(*after_root_1, *after_paragraph_6);
EXPECT_EQ(*after_paragraph_6, *after_root_1);
EXPECT_EQ(*after_root_1, *after_inline_box_8);
EXPECT_EQ(*after_inline_box_8, *after_root_1);
// According to the IAccessible2 Spec, a position inside an embedded object
// should be equivalent to a position right after it, if the former is not at
// the object's start.
EXPECT_EQ(*after_root_1, *middle_inline_box_8);
EXPECT_EQ(*middle_inline_box_8, *after_root_1);
}
TEST_F(AXPositionTest, OperatorsLessThanAndGreaterThan) {
TestPositionType null_position1 = AXNodePosition::CreateNullPosition();
ASSERT_NE(nullptr, null_position1);

@@ -3202,23 +3202,15 @@ class AXPosition {
if (IsNullPosition() || other.IsNullPosition())
return base::nullopt;
// If both positions share an anchor and either one is a text position, or
// both are tree positions, we can do a straight comparison of text offsets
// or child indices.
if (GetAnchor() == other.GetAnchor()) {
if (IsTextPosition())
return text_offset_ - other.AsTextPosition()->text_offset_;
if (other.IsTextPosition())
return AsTextPosition()->text_offset_ - other.text_offset_;
return child_index_ - other.child_index_;
}
if (GetAnchor() == other.GetAnchor())
return SlowCompareTo(other); // No optimization is necessary.
// Ancestor positions are expensive to compute. If possible, we will avoid
// doing so by computing the ancestor chain of the two positions' anchors.
// If the lowest common ancestor is neither position's anchor, we can use
// the order of the first uncommon ancestors as a proxy for the order of the
// positions.
// positions. Obviously, this heuristic cannot be used if one position is
// the ancestor of the other.
//
// In order to do that, we need to normalize text positions at the end of an
// anchor to equivalent positions at the start of the next anchor. Ignored
@@ -3231,7 +3223,8 @@ class AXPosition {
return SlowCompareTo(other);
// Normalize any text positions at the end of an anchor to equivalent
// positions at the start of the next anchor.
// positions at the start of the next anchor. This will potentially make the
// two positions not be ancestors of one another, if they originally were.
AXPositionInstance normalized_this_position = Clone();
if (normalized_this_position->IsTextPosition()) {
normalized_this_position =
@@ -3247,22 +3240,25 @@ class AXPosition {
if (normalized_this_position->IsNullPosition()) {
if (normalized_other_position->IsNullPosition()) {
// Both positions normalized to a position past the end of the whole
// content.
// content. There is no way that they could be ancestors of one another,
// so using the slow path is not required.
DCHECK_EQ(SlowCompareTo(other).value(), 0);
return 0;
}
// |this| normalized to a position past the end of the whole content.
DCHECK_GT(SlowCompareTo(other).value(), 0);
return 1;
// Since we don't know if one position is the ancestor of the other, we
// need to use the slow path.
return SlowCompareTo(other);
}
if (normalized_other_position->IsNullPosition()) {
// |other| normalized to a position past the end of the whole content.
DCHECK_LT(SlowCompareTo(other).value(), 0);
return -1;
// Since we don't know if one position is the ancestor of the other, we
// need to use the slow path.
return SlowCompareTo(other);
}
// Compute the ancestor stacks of both positions and walk them ourselves
// rather than calling LowestCommonAnchor(). That way, we can discover the
// rather than calling `LowestCommonAnchor`. That way, we can discover the
// first uncommon ancestors which we need to use in order to compare the two
// positions.
const AXNodeType* common_anchor = nullptr;
@@ -3356,14 +3352,13 @@ class AXPosition {
// change the outcome of the comparison. We also don't need to perform an
// adjustment if one of the positions is not right after the "object
// replacement character" representing the object inside which the other
// position is located, hence the "AtStartOfAnchor" checks.
// position is located, hence the `AtStartOfAnchor()` and
// `IsEmbeddedObjectInParent()` checks.
if (abs(result) == 1 &&
((IsTextPosition() && !AtStartOfAnchor() &&
this_uncommon_tree_position->IsEmbeddedObjectInParent() &&
other.AtStartOfAnchor()) ||
this_uncommon_tree_position->IsEmbeddedObjectInParent()) ||
(other.IsTextPosition() && !other.AtStartOfAnchor() &&
other_uncommon_tree_position->IsEmbeddedObjectInParent() &&
AtStartOfAnchor()))) {
other_uncommon_tree_position->IsEmbeddedObjectInParent()))) {
return SlowCompareTo(other);
}
@@ -3388,31 +3383,68 @@ class AXPosition {
if (IsNullPosition() || other.IsNullPosition())
return base::nullopt;
// If both positions share an anchor and either one is a text position, or
// both are tree positions, we can do a straight comparison of text offsets
// or child indices.
if (GetAnchor() == other.GetAnchor()) {
base::Optional<int> optional_result;
ax::mojom::TextAffinity this_affinity;
ax::mojom::TextAffinity other_affinity;
if (IsTextPosition()) {
AXPositionInstance other_text_position = other.AsTextPosition();
optional_result = text_offset_ - other_text_position->text_offset_;
this_affinity = affinity();
other_affinity = other_text_position->affinity();
}
if (other.IsTextPosition()) {
AXPositionInstance this_text_position = AsTextPosition();
optional_result = this_text_position->text_offset_ - other.text_offset_;
this_affinity = this_text_position->affinity();
other_affinity = other.affinity();
}
if (optional_result) {
// Only when the two positions are otherwise equivalent will affinity
// play a role.
if (*optional_result != 0)
return optional_result;
if (this_affinity == ax::mojom::TextAffinity::kUpstream &&
other_affinity == ax::mojom::TextAffinity::kDownstream) {
return -1;
}
if (this_affinity == ax::mojom::TextAffinity::kDownstream &&
other_affinity == ax::mojom::TextAffinity::kUpstream) {
return 1;
}
return optional_result;
}
return child_index_ - other.child_index_;
}
// It is potentially costly to compute the parent position of a text
// position, whilst computing the parent position of a tree position is
// really inexpensive. In order to find the lowest common ancestor position,
// especially if that ancestor is all the way up to the root of the tree,
// computing the parent position will need to be done repeatedly. We avoid
// the performance hit by converting both positions to tree positions and
// only falling back to computing ancestor text positions if both are text
// positions, they don't have the same anchor and one is not an ancestor of
// the other.
// only falling back to computing ancestor text positions if at least one
// position is a text position and they don't have the same anchor.
//
// Essentially, the question we need to answer is: "When are two non
// equivalent positions going to have the same lowest common ancestor
// position when converted to tree positions?" The answer is either when
// they have the same anchor and at least one is a text position, or when
// both are text positions and one is an ancestor position of the other. In
// all other cases, no information will be lost when converting to tree
// positions.
if (GetAnchor() == other.GetAnchor()) {
if (IsTextPosition())
return text_offset_ - other.AsTextPosition()->text_offset_;
if (other.IsTextPosition())
return AsTextPosition()->text_offset_ - other.text_offset_;
return child_index_ - other.child_index_;
}
// position when converted to tree positions as the ones they had before the
// conversion?" In other words, when will
// "this->AsTreePosition()->LowestCommonAncestor(*other.AsTreePosition()) ==
// other.AsTreePosition()->LowestCommonAncestor(*this->AsTreePosition())"?
// The answer is either when they have the same anchor and at least one is a
// text position, or when both are text positions and one is an ancestor
// position of the other. In all other cases, no information will be lost
// when converting to tree positions.
const AXNodeType* common_anchor = this->LowestCommonAnchor(other);
if (!common_anchor)
@@ -3421,8 +3453,43 @@ class AXPosition {
// If either of the two positions is a text position, and if one position is
// an ancestor of the other, we need to compare using text positions,
// because converting to tree positions will potentially lose information if
// the text offset is anything other than 0 or "MaxTextOffset".
// the text offset is anything other than 0 or `MaxTextOffset()`.
if (IsTextPosition() || other.IsTextPosition()) {
base::Optional<int> optional_result;
ax::mojom::TextAffinity this_affinity;
ax::mojom::TextAffinity other_affinity;
// The following two "if" blocks deal with comparisons between a text
// position and a tree position that are ancestors of one another. The
// third "if" block deals with comparisons between two text positions that
// are also ancestors of one another. Obviously, in the case of two text
// positions, affinity could always play a role (see comment in the
// relevant "if" block for an example). For the first two cases, affinity
// still needs to be taken into consideration because an "object
// replacement character" could be used to represent child nodes in the
// text of their parents. Here is an example of how affinity can influence
// a text/tree position comparison.
//
// 1 kRootWebArea
// ++2 kGenericContainer
// "<embedded_object_character><embedded_object_character>"
// ++3 kButton "Line 1"
// ++++++4 kStaticText "Line 1"
// ++++++++5 kInlineTextBox "Line 1"
// ++++6 kImage "<embedded_object_character>" kIsLineBreakingObject
//
// TextPosition anchor_id=5 text_offset=2 affinity=downstream
// annotated_text=Li<n>e 1
//
// TreePosition anchor_id=6 child_index=BEFORE_TEXT
//
// The `LowestCommonAncestor` for both will differ in its affinity:
// TextPosition anchor_id=2 text_offset=1 affinity=...
// annotated_text=embedded_object_character<embedded_object_character>
//
// The text position would create a kUpstream position, while the tree
// position would create a kDownstream position.
if (GetAnchor() == common_anchor) {
DCHECK_EQ(AsTextPosition()->GetAnchor(), common_anchor)
<< "AsTextPosition() should never modify the position's anchor.";
@@ -3436,16 +3503,20 @@ class AXPosition {
// one after it would compare as equivalent. Otherwise, screen readers
// might get stuck inside embedded objects while navigating by character
// or word. For some reproduction steps see https://crbug.com/1057831.
// Per the IA2 Spec, any selection that partially selects text inside an
// embedded object, should select the entire "object replacement
// character" in the parent object where the character appears.
// Per the IAccessible2 Spec, any selection that partially selects text
// inside an embedded object, should select the entire "object
// replacement character" in the parent object where the character
// appears.
AXPositionInstance other_text_position =
other.AsTextPosition()->CreateAncestorPosition(
common_anchor, ax::mojom::MoveDirection::kForward);
DCHECK_EQ(other_text_position->GetAnchor(), common_anchor);
return AsTextPosition()->text_offset_ -
other_text_position->text_offset_;
other_affinity = other_text_position->affinity();
AXPositionInstance this_text_position = AsTextPosition();
this_affinity = this_text_position->affinity();
optional_result = this_text_position->text_offset() -
other_text_position->text_offset();
}
if (other.GetAnchor() == common_anchor) {
@@ -3461,100 +3532,130 @@ class AXPosition {
// would compare as equivalent. Otherwise, screen readers might get
// stuck inside embedded objects while navigating by character or word.
// For some reproduction steps see https://crbug.com/1057831.
// Per the IA2 Spec, any selection that partially selects text inside an
// embedded object, should select the entire "object replacement
// character" in the parent object where the character appears.
// Per the IAccessible2 Spec, any selection that partially selects text
// inside an embedded object, should select the entire "object
// replacement character" in the parent object where the character
// appears.
AXPositionInstance this_text_position =
AsTextPosition()->CreateAncestorPosition(
common_anchor, ax::mojom::MoveDirection::kForward);
DCHECK_EQ(this_text_position->GetAnchor(), common_anchor);
return this_text_position->text_offset_ -
other.AsTextPosition()->text_offset_;
this_affinity = this_text_position->affinity();
AXPositionInstance other_text_position = other.AsTextPosition();
other_affinity = other_text_position->affinity();
optional_result = this_text_position->text_offset() -
other_text_position->AsTextPosition()->text_offset();
}
}
if (IsTextPosition() && other.IsTextPosition()) {
// All optimizations failed: We should compute and compare using the
// common ancestor text position. Computing an ancestor text position will
// automatically take affinity into consideration. It will also normalize
// text positions at the end of their anchors to equivalent positions at
// the start of the next anchor. It would also normalize positions within
// "object replacement characters" to after the character. This would
// maintain the characteristics of text position comparisons, since a
// particular offset in the tree's text representation could refer to
// multiple equivalent positions anchored to different nodes in the tree.
//
// Here is an example of how affinity can influence a text position
// comparison when at a line boundary:
//
// 1 kRootWebArea
// ++2 kTextField "Line 1Line 2"
// ++++3 kStaticText "Line 1"
// ++++++4 kInlineTextBox "Line 1"
// ++++5 kGenericContainer kIsLineBreakingObject
// ++++++6 kStaticText "Line 2"
// ++++++++7 kInlineTextBox "Line 2"
//
// TextPosition anchor_id=4 text_offset=6
// affinity=downstream annotated_text=Line 1<>
//
// TextPosition anchor_id=7 text_offset=0
// affinity=downstream annotated_text=<L>ine 2
//
// |LowestCommonAncestor| for both will be :
// TextPosition anchor_id=2 text_offset=6
// affinity=... annotated_text=Line 1<L>ine 2
//
// anchor_id=4 creates a kUpstream position, while
// anchor_id=7 creates a kDownstream position.
if (IsTextPosition() && other.IsTextPosition()) {
// We should compute and compare using the common ancestor text
// position. Computing an ancestor text position will automatically take
// affinity into consideration. It will also normalize text positions at
// the end of their anchors to equivalent positions at the start of the
// next anchor. Additionally, it would normalize positions within
// "object replacement characters" to after the character. This would
// maintain the characteristics of text position comparisons, since a
// particular offset in the tree's text representation could refer to
// multiple equivalent positions anchored to different nodes in the
// tree.
//
// Here is an example of how affinity can influence a text position
// comparison when at a line boundary:
//
// 1 kRootWebArea
// ++2 kTextField "Line 1Line 2"
// ++++3 kStaticText "Line 1"
// ++++++4 kInlineTextBox "Line 1"
// ++++5 kGenericContainer kIsLineBreakingObject
// ++++++6 kStaticText "Line 2"
// ++++++++7 kInlineTextBox "Line 2"
//
// TextPosition anchor_id=4 text_offset=6 affinity=downstream
// annotated_text=Line 1<>
//
// TextPosition anchor_id=7 text_offset=0 affinity=downstream
// annotated_text=<L>ine 2
//
// The `LowestCommonAncestor` for both will differ only in its affinity:
// TextPosition anchor_id=2 text_offset=6 affinity=...
// annotated_text=Line 1<L>ine 2
//
// anchor_id=4 would create a kUpstream position, while anchor_id=7
// would create a kDownstream position.
AXPositionInstance this_text_position_ancestor =
LowestCommonAncestor(other, ax::mojom::MoveDirection::kForward);
AXPositionInstance other_text_position_ancestor =
other.LowestCommonAncestor(*this, ax::mojom::MoveDirection::kForward);
// TODO(nektar): Fix failing DCHECK in a followup.
// DCHECK(this_text_position_ancestor->IsTextPosition());
// DCHECK(other_text_position_ancestor->IsTextPosition());
AXPositionInstance this_text_position_ancestor =
LowestCommonAncestor(other, ax::mojom::MoveDirection::kForward);
AXPositionInstance other_text_position_ancestor =
other.LowestCommonAncestor(*this,
ax::mojom::MoveDirection::kForward);
DCHECK(this_text_position_ancestor->IsTextPosition());
DCHECK(other_text_position_ancestor->IsTextPosition());
int result = this_text_position_ancestor->text_offset_ -
other_text_position_ancestor->text_offset_;
// Only when the two text positions are otherwise equivalent will affinity
// play a role.
if (result == 0) {
if (this_text_position_ancestor->affinity_ ==
ax::mojom::TextAffinity::kUpstream &&
other_text_position_ancestor->affinity_ ==
ax::mojom::TextAffinity::kDownstream) {
this_affinity = this_text_position_ancestor->affinity();
other_affinity = other_text_position_ancestor->affinity();
optional_result = this_text_position_ancestor->text_offset() -
other_text_position_ancestor->text_offset();
}
if (optional_result) {
// Only when the two positions are otherwise equivalent will affinity
// play a role.
if (*optional_result != 0)
return optional_result;
if (this_affinity == ax::mojom::TextAffinity::kUpstream &&
other_affinity == ax::mojom::TextAffinity::kDownstream) {
return -1;
}
if (this_text_position_ancestor->affinity_ ==
ax::mojom::TextAffinity::kDownstream &&
other_text_position_ancestor->affinity_ ==
ax::mojom::TextAffinity::kUpstream) {
if (this_affinity == ax::mojom::TextAffinity::kDownstream &&
other_affinity == ax::mojom::TextAffinity::kUpstream) {
return 1;
}
return optional_result;
}
return result;
}
// Either position is a tree position. To avoid a performance hit, we should
// handle comparison by converting both positions to tree positions. Such a
// conversion is valid because no information regarding the text offset
// would be needed for carrying out the comparison when the two positions
// are not ancestors of one another and when either one is a tree position.
// would be needed for carrying out the comparison when at least one of the
// positions is a tree position.
//
// We should also normalize all tree positions to the beginning of their
// anchors. Unlike text positions, two tree positions on two adjacent
// anchors, (the first position at the end of its anchor and the other at
// its beginning), should not compare as equal. This is because each
// position in the tree is unique, unlike an offset in the tree's text
// representation which can refer to more than one tree position.
// anchors, unless one of the positions is the ancestor of the other. In the
// latter case, such a normalization would potentially lose information if
// performed on any of the two positions.
// ++kRootWebArea "<embedded_object><embedded_object>"
// ++++kParagraph "Paragraph1"
// ++++kParagraph "paragraph2"
// A tree position at the end of the root web area and a tree position at
// the end of the second paragraph should compare as equal. Normalizing any
// of the two positions to the start of their respective anchors would make
// the two positions unequal.
//
// Unlike text positions, two tree positions on two adjacent anchors, (the
// first position at the end of its anchor, (i.e. an "after children"
// position), and the other at its beginning), should not compare as equal.
// This is because each position in the tree is unique, unlike an offset in
// the tree's text representation which can refer to more than one tree
// position. Meanwhile, affinity does not play any role in this case, since
// except for "after children" positions, tree positions are collapsed to
// the beginning of their parent node when computing their parent position.
AXPositionInstance this_normalized_tree_position = AsTreePosition();
AXPositionInstance other_normalized_tree_position = other.AsTreePosition();
if (GetAnchor() != common_anchor &&
other_normalized_tree_position->GetAnchor() != common_anchor) {
// None of the positions is the ancestor of the other, so normalization
// could go ahead.
this_normalized_tree_position =
this_normalized_tree_position->CreatePositionAtStartOfAnchor();
other_normalized_tree_position =
other_normalized_tree_position->CreatePositionAtStartOfAnchor();
}
AXPositionInstance this_normalized_tree_position =
AsTreePosition()->CreatePositionAtStartOfAnchor();
AXPositionInstance other_normalized_tree_position =
other.AsTreePosition()->CreatePositionAtStartOfAnchor();
AXPositionInstance this_tree_position_ancestor =
this_normalized_tree_position->CreateAncestorPosition(
common_anchor, ax::mojom::MoveDirection::kBackward);
@@ -4672,7 +4773,7 @@ class AXPosition {
// Cached members that should be lazily created on first use.
//
// In the case of a leaf position, the name of its anchor used for
// In the case of a leaf position, its inner text (in UTF16 format). Used for
// initializing a grapheme break iterator.
mutable base::string16 name_;
};

@@ -422,7 +422,7 @@ TEST_F(AXRangeTest, IsCollapsed) {
TestPositionRange tree_to_tree_range(tree_position2->Clone(),
tree_position1->Clone());
EXPECT_TRUE(tree_to_tree_range.IsCollapsed());
EXPECT_FALSE(tree_to_tree_range.IsCollapsed());
// A tree and a text position that essentially point to the same text offset
// are equivalent, even if they are anchored to a different node.

@@ -5516,10 +5516,8 @@ TEST_F(AXPlatformNodeTextRangeProviderTest,
EXPECT_FALSE(normalized_start->IsIgnored());
EXPECT_FALSE(normalized_end->IsIgnored());
// TODO(nektar): Re-enable the following two assertions after
// AXPosition::SlowCompareTo has been fixed to handle affinity correctly.
// EXPECT_LE(*GetStart(ignored_range_win.Get()), *normalized_start);
// EXPECT_LE(*GetEnd(ignored_range_win.Get()), *normalized_end);
EXPECT_LE(*GetStart(ignored_range_win.Get()), *normalized_start);
EXPECT_LE(*GetEnd(ignored_range_win.Get()), *normalized_end);
EXPECT_LE(*normalized_start, *normalized_end);
// Remove the last node, forcing |NormalizeTextRange| to normalize