From 0be138b98113b91698dd5adb8d213b9c4439862f Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 3 Jun 2026 01:28:43 +0800 Subject: [PATCH] feat(automation): pure UI node model + JSON serializer with unit test --- src/slic3r/GUI/Automation/IUiBackend.hpp | 102 ++++++++++++++++++ .../GUI/Automation/WidgetSerializer.cpp | 43 ++++++++ .../GUI/Automation/WidgetSerializer.hpp | 14 +++ tests/CMakeLists.txt | 1 + tests/automation/CMakeLists.txt | 16 +++ tests/automation/automation_tests.cpp | 4 + tests/automation/test_serializer.cpp | 32 ++++++ 7 files changed, 212 insertions(+) create mode 100644 src/slic3r/GUI/Automation/IUiBackend.hpp create mode 100644 src/slic3r/GUI/Automation/WidgetSerializer.cpp create mode 100644 src/slic3r/GUI/Automation/WidgetSerializer.hpp create mode 100644 tests/automation/CMakeLists.txt create mode 100644 tests/automation/automation_tests.cpp create mode 100644 tests/automation/test_serializer.cpp diff --git a/src/slic3r/GUI/Automation/IUiBackend.hpp b/src/slic3r/GUI/Automation/IUiBackend.hpp new file mode 100644 index 0000000000..b708d12c8a --- /dev/null +++ b/src/slic3r/GUI/Automation/IUiBackend.hpp @@ -0,0 +1,102 @@ +#pragma once +// PURE header: no wx / ImGui / GL includes. Safe to compile in the display-free +// unit-test target. Shared by the dispatcher, serializer, locator, and backends. +#include +#include +#include +#include +#include + +namespace Slic3r { namespace GUI { namespace Automation { + +enum class BackendKind { Wx, ImGui }; + +struct Rect { int x = 0, y = 0, w = 0, h = 0; }; + +// One node of the unified UI tree. `handle` is opaque (wxWindow* cast to uintptr_t +// for wx, item index for ImGui); it is used by WxUiBackend to recover concrete +// objects and is NEVER serialized. +struct UiNode { + BackendKind backend = BackendKind::Wx; + std::string id; // automation id if set, else derived path id + std::string path; // positional path, e.g. "MainFrame/Panel[2]/Button[0]" + std::string klass; // wx class name or imgui item type + std::string label; + Rect rect; // screen coordinates + bool enabled = true; + bool visible = true; + bool has_value = false; + std::string value; // when applicable (text/choice/check/slider) + std::uint64_t handle = 0; + std::vector children; // wx only; imgui items are flat under their window +}; + +struct DumpOptions { + std::optional root; // id/path to root the dump at + int max_depth = -1; // -1 = unlimited + bool visible_only = false; + bool include_imgui = true; +}; + +enum class MouseButton { Left, Right, Middle }; +enum class KeyModifier { Ctrl, Shift, Alt, Cmd }; + +struct KeyChord { + std::vector modifiers; + std::string key; // normalized lowercase: "s", "enter", "f5", "tab", ... +}; + +struct AppState { + std::string active_tab; + bool project_loaded = false; + bool slicing = false; + int slice_progress = -1; // -1 = unknown + std::optional modal_dialog; + bool foreground = false; +}; + +struct PngImage { + std::vector png; // encoded PNG bytes + int width = 0; + int height = 0; +}; + +// Thrown by backends/dispatcher; carries a JSON-RPC application error code. +struct AutomationError : std::runtime_error { + int code; + AutomationError(int code, std::string msg) + : std::runtime_error(std::move(msg)), code(code) {} +}; + +// Backend abstraction. The dispatcher orchestrates; the backend only snapshots +// and executes primitives on already-resolved nodes. +class IUiBackend { +public: + virtual ~IUiBackend() = default; + + // Force a fresh frame so transient ImGui items are recorded before a read or + // action. No-op for non-GUI backends. + virtual void refresh_ui() = 0; + + // Snapshot the UI tree (wx hierarchy + flat imgui items under their windows). + virtual UiNode dump_tree(const DumpOptions& opts) = 0; + + // Application-level state snapshot. + virtual AppState app_state() = 0; + + // Click a resolved node (uses its rect/handle). Raises/focuses first. + virtual bool click(const UiNode& node, MouseButton button, bool dbl, + const std::vector& modifiers) = 0; + // Type into the currently-focused control. + virtual bool type_text(const std::string& text) = 0; + // Send key chords (e.g. ctrl+s) to the focused window. + virtual bool send_keys(const std::vector& chords) = 0; + + // Screenshots. target == nullptr => main frame. + virtual PngImage screenshot_window(const UiNode* target) = 0; + virtual PngImage screenshot_viewport3d(std::optional plate, + std::optional width, + std::optional height) = 0; +}; + +}}} // namespace Slic3r::GUI::Automation diff --git a/src/slic3r/GUI/Automation/WidgetSerializer.cpp b/src/slic3r/GUI/Automation/WidgetSerializer.cpp new file mode 100644 index 0000000000..4db25b977e --- /dev/null +++ b/src/slic3r/GUI/Automation/WidgetSerializer.cpp @@ -0,0 +1,43 @@ +#include "WidgetSerializer.hpp" + +namespace Slic3r { namespace GUI { namespace Automation { + +static const char* backend_name(BackendKind k) { + return k == BackendKind::Wx ? "wx" : "imgui"; +} + +nlohmann::json node_to_json(const UiNode& node, bool include_children) { + nlohmann::json j; + j["backend"] = backend_name(node.backend); + j["id"] = node.id; + j["path"] = node.path; + j["class"] = node.klass; + j["label"] = node.label; + j["rect"] = { {"x", node.rect.x}, {"y", node.rect.y}, + {"w", node.rect.w}, {"h", node.rect.h} }; + j["enabled"] = node.enabled; + j["visible"] = node.visible; + if (node.has_value) + j["value"] = node.value; + if (include_children && node.backend == BackendKind::Wx) { + nlohmann::json arr = nlohmann::json::array(); + for (const UiNode& c : node.children) + arr.push_back(node_to_json(c, true)); + j["children"] = std::move(arr); + } + return j; +} + +nlohmann::json app_state_to_json(const AppState& s) { + nlohmann::json j; + j["active_tab"] = s.active_tab; + j["project_loaded"] = s.project_loaded; + j["slicing"] = s.slicing; + j["slice_progress"] = s.slice_progress; + j["foreground"] = s.foreground; + if (s.modal_dialog) + j["modal_dialog"] = *s.modal_dialog; + return j; +} + +}}} // namespace diff --git a/src/slic3r/GUI/Automation/WidgetSerializer.hpp b/src/slic3r/GUI/Automation/WidgetSerializer.hpp new file mode 100644 index 0000000000..581a994326 --- /dev/null +++ b/src/slic3r/GUI/Automation/WidgetSerializer.hpp @@ -0,0 +1,14 @@ +#pragma once +#include "IUiBackend.hpp" +#include + +namespace Slic3r { namespace GUI { namespace Automation { + +// Serialize a node to the unified JSON shape from the design spec (ยง5). +// `include_children` controls recursion into UiNode::children. +nlohmann::json node_to_json(const UiNode& node, bool include_children); + +// Serialize an application-state snapshot. +nlohmann::json app_state_to_json(const AppState& state); + +}}} // namespace diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6b93962a33..2d148f0276 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,5 +52,6 @@ add_subdirectory(libslic3r) add_subdirectory(slic3rutils) add_subdirectory(fff_print) add_subdirectory(sla_print) +add_subdirectory(automation) diff --git a/tests/automation/CMakeLists.txt b/tests/automation/CMakeLists.txt new file mode 100644 index 0000000000..6bc479292b --- /dev/null +++ b/tests/automation/CMakeLists.txt @@ -0,0 +1,16 @@ +get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) + +add_executable(${_TEST_NAME}_tests + automation_tests.cpp + test_serializer.cpp + ${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/WidgetSerializer.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 +# path (so resolves). The rest of the codebase reaches +# via the deps_src root that admesh re-exports transitively; this pure unit-test +# target does not link admesh, so add the deps_src root explicitly to match. +target_include_directories(${_TEST_NAME}_tests PRIVATE ${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/deps_src) +set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests") +orcaslicer_copy_test_dlls() +catch_discover_tests(${_TEST_NAME}_tests) diff --git a/tests/automation/automation_tests.cpp b/tests/automation/automation_tests.cpp new file mode 100644 index 0000000000..350e77d61e --- /dev/null +++ b/tests/automation/automation_tests.cpp @@ -0,0 +1,4 @@ +// Catch2 provides main() via Catch2::Catch2WithMain. This TU exists so the +// executable has at least one source plus a stable name; per-feature TEST_CASEs +// live in the test_*.cpp files. +#include diff --git a/tests/automation/test_serializer.cpp b/tests/automation/test_serializer.cpp new file mode 100644 index 0000000000..c36c57a9d7 --- /dev/null +++ b/tests/automation/test_serializer.cpp @@ -0,0 +1,32 @@ +#include +#include "slic3r/GUI/Automation/WidgetSerializer.hpp" + +using namespace Slic3r::GUI::Automation; + +TEST_CASE("node_to_json emits the unified node shape", "[automation][serializer]") { + UiNode n; + n.backend = BackendKind::Wx; + n.id = "btn_slice"; + n.path = "MainFrame/Panel[2]/Button[0]"; + n.klass = "Button"; + n.label = "Slice plate"; + n.rect = {100, 200, 120, 32}; + n.enabled = true; + n.visible = true; + + const nlohmann::json j = node_to_json(n, /*include_children*/ false); + + CHECK(j.at("backend") == "wx"); + CHECK(j.at("id") == "btn_slice"); + CHECK(j.at("path") == "MainFrame/Panel[2]/Button[0]"); + CHECK(j.at("class") == "Button"); + CHECK(j.at("label") == "Slice plate"); + CHECK(j.at("rect").at("x") == 100); + CHECK(j.at("rect").at("w") == 120); + CHECK(j.at("enabled") == true); + CHECK(j.at("visible") == true); + // `handle` must never leak into JSON. + CHECK_FALSE(j.contains("handle")); + // No value set -> no "value" key. + CHECK_FALSE(j.contains("value")); +}