| // Copyright 2022 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "services/screen_ai/proto/main_content_extractor_proto_convertor.h" |
| |
| #include <array> |
| #include <string_view> |
| |
| #include "base/compiler_specific.h" |
| #include "base/files/file_path.h" |
| #include "base/files/file_util.h" |
| #include "base/path_service.h" |
| #include "base/strings/stringprintf.h" |
| #include "base/test/test_proto_loader.h" |
| #include "build/build_config.h" |
| #include "services/screen_ai/proto/view_hierarchy.pb.h" |
| #include "testing/gtest/include/gtest/gtest.h" |
| #include "ui/accessibility/accessibility_features.h" |
| #include "ui/accessibility/ax_node_data.h" |
| #include "ui/accessibility/ax_tree.h" |
| #include "ui/accessibility/ax_tree_update.h" |
| #include "ui/gfx/geometry/rect.h" |
| |
| namespace { |
| |
| // To update the test expectations: |
| // 1- Set the following to 'true' to get debug protos. |
| // 2- Replace the expected proto by the generated file. |
| #define WRITE_DEBUG_PROTO false |
| |
| // A dummy tree node definition for PreOrderTreeGeneration. |
| constexpr int kMaxChildInTemplate = 3; |
| struct NodeTemplate { |
| ui::AXNodeID node_id; |
| int child_count; |
| std::array<ui::AXNodeID, kMaxChildInTemplate> child_ids; |
| }; |
| |
| ui::AXTreeUpdate CreateAXTreeUpdateFromTemplate(int root_id, |
| NodeTemplate* nodes_template, |
| int nodes_count) { |
| ui::AXTreeUpdate update; |
| update.root_id = root_id; |
| |
| for (int i = 0; i < nodes_count; i++) { |
| ui::AXNodeData node; |
| node.id = UNSAFE_TODO(nodes_template[i]).node_id; |
| for (int j = 0; j < UNSAFE_TODO(nodes_template[i]).child_count; j++) { |
| node.child_ids.push_back(UNSAFE_TODO(nodes_template[i]).child_ids[j]); |
| } |
| node.relative_bounds.bounds = gfx::RectF(0, 0, 100, 100); |
| update.nodes.push_back(node); |
| } |
| return update; |
| } |
| |
| int GetAxNodeID(const ::screenai::UiElement& ui_element) { |
| for (const auto& attribute : ui_element.attributes()) { |
| if (attribute.name() == "axnode_id") |
| return attribute.int_value(); |
| } |
| return static_cast<int>(ui::kInvalidAXNodeID); |
| } |
| |
| gfx::RectF GetBoundingBox(const ::screenai::UiElement& ui_element) { |
| return gfx::RectF(ui_element.bounding_box().left(), |
| ui_element.bounding_box().top(), |
| ui_element.bounding_box().right() - |
| ui_element.bounding_box().left(), |
| ui_element.bounding_box().bottom() - |
| ui_element.bounding_box().top()); |
| } |
| |
| gfx::RectF GetBoundingBoxPixels(const ::screenai::UiElement& ui_element) { |
| return gfx::RectF(ui_element.bounding_box_pixels().left(), |
| ui_element.bounding_box_pixels().top(), |
| ui_element.bounding_box_pixels().right() - |
| ui_element.bounding_box_pixels().left(), |
| ui_element.bounding_box_pixels().bottom() - |
| ui_element.bounding_box_pixels().top()); |
| } |
| |
| base::FilePath GetTestFilePath(std::string_view file_name) { |
| base::FilePath path; |
| EXPECT_TRUE(base::PathService::Get(base::DIR_SRC_TEST_DATA_ROOT, &path)); |
| return path.AppendASCII("services/test/data/screen_ai") |
| .AppendASCII(file_name); |
| } |
| |
| void WriteDebugProto(const std::string& serialized_proto, |
| const std::string& file_name) { |
| if (!WRITE_DEBUG_PROTO) |
| return; |
| |
| base::FilePath path; |
| EXPECT_TRUE(base::PathService::Get(base::DIR_TEMP, &path)); |
| path = path.AppendASCII(file_name); |
| |
| if (base::WriteFile(path, serialized_proto)) { |
| LOG(INFO) << "Debug proto is written to: " << path; |
| } else { |
| LOG(ERROR) << "Could not write debug proto to: " << path; |
| } |
| } |
| |
| std::string ConvertProtoToText(const std::string& proto_binary) { |
| base::FilePath descriptor_full_path; |
| if (!base::PathService::Get(base::DIR_GEN_TEST_DATA_ROOT, |
| &descriptor_full_path)) { |
| LOG(ERROR) << "Generated test data root not found!"; |
| return ""; |
| } |
| descriptor_full_path = descriptor_full_path.AppendASCII( |
| "services/screen_ai/proto/view_hierarchy.descriptor"); |
| |
| screenai::ViewHierarchy proto; |
| base::TestProtoLoader loader(descriptor_full_path, proto.GetTypeName()); |
| std::string serialized_message; |
| loader.PrintToText(proto_binary, serialized_message); |
| return serialized_message; |
| } |
| |
| ui::AXTree CreateSampleTree() { |
| // AXTree title=Test Tree |
| // id=1 rootWebArea clips_children child_ids=2,4,6 (4, 8)-(15, 16) |
| // non_atomic_text_field_root=true |
| // ++id=2 paragraph child_ids=3 (1, 1)-(5, 5) is_line_breaking_object=true |
| // ++++id=3 staticText EDITABLE name=StaticText00 (20, 20)-(5, 5) |
| // ++id=4 paragraph clips_children child_ids=5 (1, 6)-(5, 5) |
| // ++is_line_breaking_object=true |
| // ++++id=5 staticText name=StaticText10 offset_container_id=4 (6, 6)-(5, 5) |
| // ++id=6 paragraph child_ids=7 (0, 10)-(5, 5) is_line_breaking_object=true |
| // ++++id=7 link LINKED child_ids=8,9 (1, 1)-(1, 1) |
| // ++++++id=8 staticText name=StaticText200 (1, 2)-(1, 1) |
| // ++++++id=9 staticText name=Static Text 201 child_ids=10,11,12 (1, 3)-(1, 4) |
| // ++++++++id=10 inlineTextBox name=Static (1, 3)-(1, 1) |
| // ++++++++id=11 inlineTextBox name=Text (1, 4)-(1, 1) |
| // ++++++++id=12 inlineTextBox name=201 (1, 5)-(1, 1) |
| |
| ui::AXNodeData root; |
| ui::AXNodeData paragraph_0; |
| ui::AXNodeData static_text_0_0; |
| ui::AXNodeData paragraph_1; |
| ui::AXNodeData static_text_1_0; |
| ui::AXNodeData paragraph_2; |
| ui::AXNodeData link_2_0; |
| ui::AXNodeData static_text_2_0_0; |
| ui::AXNodeData static_text_2_0_1; |
| ui::AXNodeData inline_text_box_2_0_1_0; |
| ui::AXNodeData inline_text_box_2_0_1_1; |
| ui::AXNodeData inline_text_box_2_0_1_2; |
| |
| root.id = 1; |
| paragraph_0.id = 2; |
| static_text_0_0.id = 3; |
| paragraph_1.id = 4; |
| static_text_1_0.id = 5; |
| paragraph_2.id = 6; |
| link_2_0.id = 7; |
| static_text_2_0_0.id = 8; |
| static_text_2_0_1.id = 9; |
| inline_text_box_2_0_1_0.id = 10; |
| inline_text_box_2_0_1_1.id = 11; |
| inline_text_box_2_0_1_2.id = 12; |
| |
| root.role = ax::mojom::Role::kRootWebArea; |
| root.AddBoolAttribute(ax::mojom::BoolAttribute::kNonAtomicTextFieldRoot, |
| true); |
| root.relative_bounds.bounds = gfx::RectF(4, 8, 15, 16); |
| // Setting ClipsChildren ensures that the tree does not grow its size to |
| // contain all children. |
| root.AddBoolAttribute(ax::mojom::BoolAttribute::kClipsChildren, true); |
| root.child_ids = {paragraph_0.id, paragraph_1.id, paragraph_2.id}; |
| |
| paragraph_0.role = ax::mojom::Role::kParagraph; |
| paragraph_0.AddBoolAttribute(ax::mojom::BoolAttribute::kIsLineBreakingObject, |
| true); |
| paragraph_0.relative_bounds.bounds = gfx::RectF(1, 1, 5, 5); |
| paragraph_0.child_ids = {static_text_0_0.id}; |
| |
| static_text_0_0.role = ax::mojom::Role::kStaticText; |
| static_text_0_0.AddState(ax::mojom::State::kEditable); |
| static_text_0_0.SetName("StaticText00"); |
| // Since `offset_container_id` is not set, the position is relative to root. |
| static_text_0_0.relative_bounds.bounds = gfx::RectF(20, 20, 5, 5); |
| |
| paragraph_1.role = ax::mojom::Role::kParagraph; |
| paragraph_1.AddBoolAttribute(ax::mojom::BoolAttribute::kIsLineBreakingObject, |
| true); |
| paragraph_1.AddBoolAttribute(ax::mojom::BoolAttribute::kClipsChildren, true); |
| paragraph_1.relative_bounds.bounds = gfx::RectF(1, 6, 5, 5); |
| paragraph_1.child_ids = {static_text_1_0.id}; |
| |
| static_text_1_0.role = ax::mojom::Role::kStaticText; |
| static_text_1_0.SetName("StaticText10"); |
| static_text_1_0.relative_bounds.bounds = gfx::RectF(6, 6, 5, 5); |
| static_text_1_0.relative_bounds.offset_container_id = paragraph_1.id; |
| |
| paragraph_2.role = ax::mojom::Role::kParagraph; |
| paragraph_2.AddBoolAttribute(ax::mojom::BoolAttribute::kIsLineBreakingObject, |
| true); |
| paragraph_2.relative_bounds.bounds = gfx::RectF(0, 10, 5, 5); |
| paragraph_2.child_ids = {link_2_0.id}; |
| |
| link_2_0.role = ax::mojom::Role::kLink; |
| link_2_0.AddState(ax::mojom::State::kLinked); |
| link_2_0.relative_bounds.bounds = gfx::RectF(1, 1, 1, 1); |
| link_2_0.child_ids = {static_text_2_0_0.id, static_text_2_0_1.id}; |
| |
| static_text_2_0_0.role = ax::mojom::Role::kStaticText; |
| static_text_2_0_0.SetName("StaticText200"); |
| static_text_2_0_0.relative_bounds.bounds = gfx::RectF(1, 2, 1, 1); |
| |
| static_text_2_0_1.role = ax::mojom::Role::kStaticText; |
| static_text_2_0_1.SetName("Static Text 201"); |
| static_text_2_0_1.relative_bounds.bounds = gfx::RectF(1, 3, 1, 4); |
| static_text_2_0_1.child_ids = {inline_text_box_2_0_1_0.id, |
| inline_text_box_2_0_1_1.id, |
| inline_text_box_2_0_1_2.id}; |
| |
| inline_text_box_2_0_1_0.role = ax::mojom::Role::kInlineTextBox; |
| inline_text_box_2_0_1_0.SetName("Static"); |
| inline_text_box_2_0_1_0.relative_bounds.bounds = gfx::RectF(1, 3, 1, 1); |
| |
| inline_text_box_2_0_1_1.role = ax::mojom::Role::kInlineTextBox; |
| inline_text_box_2_0_1_1.SetName("Text"); |
| inline_text_box_2_0_1_1.relative_bounds.bounds = gfx::RectF(1, 4, 1, 1); |
| |
| inline_text_box_2_0_1_2.role = ax::mojom::Role::kInlineTextBox; |
| inline_text_box_2_0_1_2.SetName("201"); |
| inline_text_box_2_0_1_2.relative_bounds.bounds = gfx::RectF(1, 5, 1, 1); |
| |
| ui::AXTreeUpdate initial_state; |
| initial_state.root_id = root.id; |
| initial_state.nodes = {root, |
| paragraph_0, |
| static_text_0_0, |
| paragraph_1, |
| static_text_1_0, |
| paragraph_2, |
| link_2_0, |
| static_text_2_0_0, |
| static_text_2_0_1, |
| inline_text_box_2_0_1_0, |
| inline_text_box_2_0_1_1, |
| inline_text_box_2_0_1_2}; |
| initial_state.has_tree_data = true; |
| |
| ui::AXTreeData tree_data; |
| tree_data.tree_id = ui::AXTreeID::CreateNewAXTreeID(); |
| tree_data.title = "Test Tree"; |
| initial_state.tree_data = tree_data; |
| |
| return ui::AXTree(initial_state); |
| } |
| |
| ui::AXTree CreateSampleTreeWithBounds() { |
| // AXTree title=Test Tree |
| // id=1 rootWebArea child_ids=2,4,5 (0, 0)-(1, 1) |
| // ++id=2 genericContainer child_ids=3 (0, 0)-(10, 20) clips_children=true |
| // ++++id=3 genericContainer (0, 0)-(30, 40) |
| // ++id=4 genericContainer (0, 0)-(50, 60) ignored=true |
| // ++id=5 genericContainer (0, 0)-(70, 80) invisible=true |
| |
| ui::AXNodeData root; |
| ui::AXNodeData generic_container_2; |
| ui::AXNodeData generic_container_3; |
| ui::AXNodeData generic_container_4; |
| ui::AXNodeData generic_container_5; |
| |
| root.id = 1; |
| generic_container_2.id = 2; |
| generic_container_3.id = 3; |
| generic_container_4.id = 4; |
| generic_container_5.id = 5; |
| |
| root.role = ax::mojom::Role::kRootWebArea; |
| root.relative_bounds.bounds = gfx::RectF(0, 0, 1, 1); |
| root.child_ids = {generic_container_2.id, generic_container_4.id, generic_container_5.id}; |
| |
| generic_container_2.role = ax::mojom::Role::kGenericContainer; |
| generic_container_2.relative_bounds.bounds = gfx::RectF(0, 0, 10, 20); |
| generic_container_2.AddBoolAttribute(ax::mojom::BoolAttribute::kClipsChildren, |
| true); |
| generic_container_2.child_ids = {generic_container_3.id}; |
| |
| generic_container_3.role = ax::mojom::Role::kGenericContainer; |
| generic_container_3.relative_bounds.bounds = gfx::RectF(0, 0, 30, 40); |
| |
| generic_container_4.role = ax::mojom::Role::kGenericContainer; |
| generic_container_4.relative_bounds.bounds = gfx::RectF(0, 0, 50, 60); |
| generic_container_4.AddState(ax::mojom::State::kIgnored); |
| |
| generic_container_5.role = ax::mojom::Role::kGenericContainer; |
| generic_container_5.relative_bounds.bounds = gfx::RectF(0, 0, 70, 80); |
| generic_container_5.AddState(ax::mojom::State::kInvisible); |
| |
| ui::AXTreeUpdate initial_state; |
| initial_state.root_id = root.id; |
| initial_state.nodes = {root, generic_container_2, generic_container_3, |
| generic_container_4, generic_container_5}; |
| initial_state.has_tree_data = true; |
| |
| ui::AXTreeData tree_data; |
| tree_data.tree_id = ui::AXTreeID::CreateNewAXTreeID(); |
| tree_data.title = "Test Tree"; |
| initial_state.tree_data = tree_data; |
| |
| return ui::AXTree(initial_state); |
| } |
| |
| } // namespace |
| |
| namespace screen_ai { |
| |
| using MainContentExtractorProtoConvertorTest = testing::Test; |
| |
| // Tests if the given tree is properly traversed and new ids are assigned. |
| TEST_F(MainContentExtractorProtoConvertorTest, PreOrderTreeGeneration) { |
| // Input Tree: |
| // +-- 1 |
| // +-- 2 |
| // +-- 7 |
| // +-- 8 |
| // +-- 3 |
| // +-- 4 |
| // +-- 5 |
| // +-- 6 |
| // +-- 9 |
| // +-- -20 |
| |
| // Input tree is added in shuffled order to avoid order assumption. |
| NodeTemplate input_tree[] = { |
| {1, 3, {2, 4, -20}}, {4, 3, {5, 6, 9}}, {6, 0, {}}, {5, 0, {}}, |
| {2, 2, {7, 8}}, {8, 1, {3}}, {3, 0, {}}, {7, 0, {}}, |
| {9, 0, {}}, {-20, 0, {}}}; |
| const int nodes_count = sizeof(input_tree) / sizeof(NodeTemplate); |
| |
| // Expected order of nodes in the output. |
| auto expected_order = std::to_array<int>({1, 2, 7, 8, 3, 4, 5, 6, 9, -20}); |
| |
| // Create the tree, convert it, and decode from proto. |
| ui::AXTreeUpdate tree_update = |
| CreateAXTreeUpdateFromTemplate(1, input_tree, nodes_count); |
| ui::AXTree tree(tree_update); |
| std::optional<ViewHierarchyAndTreeSize> result = |
| SnapshotToViewHierarchy(tree); |
| ASSERT_TRUE(result); |
| screenai::ViewHierarchy view_hierarchy; |
| ASSERT_TRUE(view_hierarchy.ParseFromString(result->serialized_proto)); |
| |
| // Verify. |
| EXPECT_EQ(view_hierarchy.ui_elements().size(), nodes_count); |
| for (int i = 0; i < nodes_count; i++) { |
| const screenai::UiElement& ui_element = view_hierarchy.ui_elements(i); |
| |
| // Expect node to be correctly re-ordered. |
| EXPECT_EQ(expected_order[i], GetAxNodeID(ui_element)); |
| |
| // Expect node at index 'i' has id 'i' |
| EXPECT_EQ(ui_element.id(), i); |
| } |
| } |
| |
| TEST_F(MainContentExtractorProtoConvertorTest, ProtoTest) { |
| ui::AXTree tree = CreateSampleTree(); |
| |
| std::optional<ViewHierarchyAndTreeSize> result = |
| SnapshotToViewHierarchy(tree); |
| ASSERT_TRUE(result); |
| std::string generated_proto = ConvertProtoToText(result->serialized_proto); |
| WriteDebugProto(generated_proto, "expected_proto.pbtxt"); |
| std::string expected_proto; |
| ASSERT_TRUE(base::ReadFileToString(GetTestFilePath("expected_proto.pbtxt"), |
| &expected_proto)); |
| ASSERT_EQ(generated_proto, expected_proto); |
| } |
| |
| // Tests if the bounds are computed correctly. |
| TEST_F(MainContentExtractorProtoConvertorTest, BoundsComputation) { |
| ui::AXTree tree = CreateSampleTreeWithBounds(); |
| std::optional<ViewHierarchyAndTreeSize> result = |
| SnapshotToViewHierarchy(tree); |
| ASSERT_TRUE(result); |
| screenai::ViewHierarchy view_hierarchy; |
| ASSERT_TRUE(view_hierarchy.ParseFromString(result->serialized_proto)); |
| |
| // The root element should have the same bounds as the tree. The tree should |
| // have the bounds of (0, 0)-(10, 20) because the other elements are ignored, |
| // invisible, or clipped. |
| const screenai::UiElement& root_ui_element = view_hierarchy.ui_elements(0); |
| EXPECT_EQ(gfx::RectF(0, 0, 10, 20), GetBoundingBoxPixels(root_ui_element)); |
| EXPECT_EQ(gfx::RectF(0, 0, 1, 1), GetBoundingBox(root_ui_element)); |
| } |
| |
| |
| } // namespace screen_ai |