From ddd1967bff64566d4b51a61d35fb8d873e055973 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 3 Jun 2026 01:38:13 +0800 Subject: [PATCH] feat(automation): pure locator (id/path/predicate) with unit tests --- src/slic3r/GUI/Automation/Locator.cpp | 62 ++++++++++++++++++ src/slic3r/GUI/Automation/Locator.hpp | 37 +++++++++++ tests/automation/CMakeLists.txt | 2 + tests/automation/test_locator.cpp | 92 +++++++++++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 src/slic3r/GUI/Automation/Locator.cpp create mode 100644 src/slic3r/GUI/Automation/Locator.hpp create mode 100644 tests/automation/test_locator.cpp diff --git a/src/slic3r/GUI/Automation/Locator.cpp b/src/slic3r/GUI/Automation/Locator.cpp new file mode 100644 index 0000000000..690facff6c --- /dev/null +++ b/src/slic3r/GUI/Automation/Locator.cpp @@ -0,0 +1,62 @@ +#include "Locator.hpp" + +namespace Slic3r { namespace GUI { namespace Automation { + +static void flatten_into(const UiNode& n, std::vector& out) { + out.push_back(&n); + for (const UiNode& c : n.children) + flatten_into(c, out); +} + +std::vector flatten(const UiNode& root) { + std::vector out; + flatten_into(root, out); + return out; +} + +static bool matches_predicate(const UiNode& n, const Target& t) { + if (t.backend && n.backend != *t.backend) return false; + if (t.name && !(n.id == *t.name || n.label == *t.name)) return false; + if (t.klass && n.klass != *t.klass) return false; + if (t.label && n.label != *t.label) return false; + if (t.value && !(n.has_value && n.value == *t.value)) return false; + return true; +} + +std::vector find_matches(const UiNode& root, const Target& target) { + const auto all = flatten(root); + std::vector out; + + // Resolution order: exact id -> exact path -> predicate. + if (target.id) { + for (const UiNode* n : all) + if (n->id == *target.id) out.push_back(n); + return out; + } + if (target.path) { + for (const UiNode* n : all) + if (n->path == *target.path) out.push_back(n); + return out; + } + if (target.empty()) + return out; // nothing to match on + for (const UiNode* n : all) + if (matches_predicate(*n, target)) out.push_back(n); + return out; +} + +bool evaluate_state(const UiNode* node, WaitState state, + const std::optional& expected_value) { + if (node == nullptr) + return false; + switch (state) { + case WaitState::Exists: return true; + case WaitState::Visible: return node->visible; + case WaitState::Enabled: return node->enabled && node->visible; + case WaitState::Value: + return node->has_value && expected_value && node->value == *expected_value; + } + return false; +} + +}}} // namespace diff --git a/src/slic3r/GUI/Automation/Locator.hpp b/src/slic3r/GUI/Automation/Locator.hpp new file mode 100644 index 0000000000..16b6b2714f --- /dev/null +++ b/src/slic3r/GUI/Automation/Locator.hpp @@ -0,0 +1,37 @@ +#pragma once +#include "IUiBackend.hpp" +#include +#include +#include + +namespace Slic3r { namespace GUI { namespace Automation { + +// A target specification. Resolution order: id -> path -> predicate +// (name OR class OR label OR value, all provided fields must match). +struct Target { + std::optional id; + std::optional path; + std::optional name; // matches id OR label + std::optional klass; + std::optional label; + std::optional value; + std::optional backend; + bool empty() const { + return !id && !path && !name && !klass && !label && !value; + } +}; + +// Depth-first flatten of a tree into stable-ordered pointers (parents before children). +std::vector flatten(const UiNode& root); + +// All nodes matching the target spec (resolution-order aware). +std::vector find_matches(const UiNode& root, const Target& target); + +enum class WaitState { Exists, Visible, Enabled, Value }; + +// True if `node` satisfies the wait condition. A null node only satisfies a +// negative... here we keep it simple: null => false for all states. +bool evaluate_state(const UiNode* node, WaitState state, + const std::optional& expected_value); + +}}} // namespace diff --git a/tests/automation/CMakeLists.txt b/tests/automation/CMakeLists.txt index 6bc479292b..cd9a3cf041 100644 --- a/tests/automation/CMakeLists.txt +++ b/tests/automation/CMakeLists.txt @@ -3,7 +3,9 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) add_executable(${_TEST_NAME}_tests automation_tests.cpp test_serializer.cpp + test_locator.cpp ${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/WidgetSerializer.cpp + ${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/Locator.cpp ) target_link_libraries(${_TEST_NAME}_tests test_common Catch2::Catch2WithMain nlohmann_json) # The nlohmann_json INTERFACE target only exposes deps_src/nlohmann on the include diff --git a/tests/automation/test_locator.cpp b/tests/automation/test_locator.cpp new file mode 100644 index 0000000000..b219828a19 --- /dev/null +++ b/tests/automation/test_locator.cpp @@ -0,0 +1,92 @@ +#include +#include "slic3r/GUI/Automation/Locator.hpp" + +using namespace Slic3r::GUI::Automation; + +namespace { +UiNode make_tree() { + UiNode root; + root.klass = "MainFrame"; + root.path = "MainFrame"; + + UiNode panel; + panel.klass = "Panel"; + panel.path = "MainFrame/Panel[0]"; + + UiNode slice; + slice.id = "btn_slice"; + slice.klass = "Button"; + slice.label = "Slice plate"; + slice.path = "MainFrame/Panel[0]/Button[0]"; + + UiNode export_btn; + export_btn.id = "btn_export"; + export_btn.klass = "Button"; + export_btn.label = "Export"; + export_btn.path = "MainFrame/Panel[0]/Button[1]"; + + UiNode dup; // duplicate label, used for ambiguity tests + dup.klass = "Button"; + dup.label = "Export"; + dup.path = "MainFrame/Panel[0]/Button[2]"; + + panel.children = {slice, export_btn, dup}; + root.children = {panel}; + return root; +} +} // namespace + +TEST_CASE("flatten yields parents before children", "[automation][locator]") { + const auto tree = make_tree(); + const auto all = flatten(tree); + REQUIRE(all.size() == 5); + CHECK(all.front()->klass == "MainFrame"); +} + +TEST_CASE("find_matches by exact id returns one", "[automation][locator]") { + const auto tree = make_tree(); + Target t; t.id = "btn_slice"; + const auto m = find_matches(tree, t); + REQUIRE(m.size() == 1); + CHECK(m[0]->label == "Slice plate"); +} + +TEST_CASE("find_matches by exact path returns one", "[automation][locator]") { + const auto tree = make_tree(); + Target t; t.path = "MainFrame/Panel[0]/Button[1]"; + const auto m = find_matches(tree, t); + REQUIRE(m.size() == 1); + CHECK(m[0]->id == "btn_export"); +} + +TEST_CASE("find_matches by predicate (label) can be ambiguous", + "[automation][locator]") { + const auto tree = make_tree(); + Target t; t.label = "Export"; + const auto m = find_matches(tree, t); + CHECK(m.size() == 2); // btn_export + the duplicate +} + +TEST_CASE("find_matches predicate combines fields (AND)", + "[automation][locator]") { + const auto tree = make_tree(); + Target t; t.label = "Export"; t.klass = "Button"; t.id = std::nullopt; + // id/path absent -> predicate mode. Both fields must match. + t.id = std::nullopt; + const auto m = find_matches(tree, t); + CHECK(m.size() == 2); +} + +TEST_CASE("find_matches by name matches id OR label", "[automation][locator]") { + const auto tree = make_tree(); + Target byId; byId.name = "btn_slice"; + CHECK(find_matches(tree, byId).size() == 1); + Target byLabel; byLabel.name = "Slice plate"; + CHECK(find_matches(tree, byLabel).size() == 1); +} + +TEST_CASE("find_matches not found returns empty", "[automation][locator]") { + const auto tree = make_tree(); + Target t; t.id = "nope"; + CHECK(find_matches(tree, t).empty()); +}