feat(automation): pure UI node model + JSON serializer with unit test

This commit is contained in:
SoftFever
2026-06-03 01:28:43 +08:00
parent 11301086a7
commit 0be138b981
7 changed files with 212 additions and 0 deletions

View File

@@ -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 <cstdint>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>
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<UiNode> children; // wx only; imgui items are flat under their window
};
struct DumpOptions {
std::optional<std::string> 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<KeyModifier> 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<std::string> modal_dialog;
bool foreground = false;
};
struct PngImage {
std::vector<unsigned char> 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<KeyModifier>& 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<KeyChord>& chords) = 0;
// Screenshots. target == nullptr => main frame.
virtual PngImage screenshot_window(const UiNode* target) = 0;
virtual PngImage screenshot_viewport3d(std::optional<int> plate,
std::optional<int> width,
std::optional<int> height) = 0;
};
}}} // namespace Slic3r::GUI::Automation

View File

@@ -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

View File

@@ -0,0 +1,14 @@
#pragma once
#include "IUiBackend.hpp"
#include <nlohmann/json.hpp>
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

View File

@@ -52,5 +52,6 @@ add_subdirectory(libslic3r)
add_subdirectory(slic3rutils)
add_subdirectory(fff_print)
add_subdirectory(sla_print)
add_subdirectory(automation)

View File

@@ -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 <json.hpp> resolves). The rest of the codebase reaches <nlohmann/json.hpp>
# 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)

View File

@@ -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 <catch2/catch_all.hpp>

View File

@@ -0,0 +1,32 @@
#include <catch2/catch_all.hpp>
#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"));
}