From 5a2f03adee4f8631d4bc09e3b42be49515c85ecd Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 3 Jun 2026 02:01:40 +0800 Subject: [PATCH] feat(automation): input.click / input.type / input.key handlers --- .../GUI/Automation/JsonRpcDispatcher.cpp | 120 +++++++++++++++++- .../GUI/Automation/JsonRpcDispatcher.hpp | 5 + tests/automation/test_dispatcher.cpp | 54 ++++++++ 3 files changed, 176 insertions(+), 3 deletions(-) diff --git a/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp b/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp index d98277d9cc..13882f976a 100644 --- a/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp +++ b/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp @@ -54,6 +54,80 @@ DumpOptions parse_dump_options(const nlohmann::json& p) { } } // namespace +namespace { +MouseButton parse_button(const nlohmann::json& p) { + auto b = opt_str(p, "button"); + if (b && *b == "right") return MouseButton::Right; + if (b && *b == "middle") return MouseButton::Middle; + return MouseButton::Left; +} + +std::vector parse_modifiers(const nlohmann::json& p) { + std::vector mods; + if (p.is_object() && p.contains("modifiers") && p.at("modifiers").is_array()) { + for (const auto& m : p.at("modifiers")) { + if (!m.is_string()) continue; + const std::string s = m.get(); + if (s == "ctrl") mods.push_back(KeyModifier::Ctrl); + else if (s == "shift") mods.push_back(KeyModifier::Shift); + else if (s == "alt") mods.push_back(KeyModifier::Alt); + else if (s == "cmd" || s == "meta") mods.push_back(KeyModifier::Cmd); + } + } + return mods; +} + +// Parse one chord token list (already split): the last token is the key, the +// earlier ones are modifiers. +KeyChord chord_from_tokens(const std::vector& tokens) { + KeyChord c; + for (size_t i = 0; i < tokens.size(); ++i) { + const std::string& t = tokens[i]; + const bool is_mod = (t == "ctrl" || t == "shift" || t == "alt" || + t == "cmd" || t == "meta"); + if (is_mod && i + 1 < tokens.size()) { + if (t == "ctrl") c.modifiers.push_back(KeyModifier::Ctrl); + else if (t == "shift") c.modifiers.push_back(KeyModifier::Shift); + else if (t == "alt") c.modifiers.push_back(KeyModifier::Alt); + else c.modifiers.push_back(KeyModifier::Cmd); + } else { + c.key = t; // last token (or a lone token) is the key + } + } + return c; +} + +std::vector split(const std::string& s, char delim) { + std::vector out; + std::string cur; + for (char ch : s) { + if (ch == delim) { if (!cur.empty()) out.push_back(cur); cur.clear(); } + else cur.push_back(ch); + } + if (!cur.empty()) out.push_back(cur); + return out; +} + +// "keys" may be a string ("ctrl+s") or an array (["ctrl","s"]). Returns one chord. +std::vector parse_keys(const nlohmann::json& params) { + if (!params.is_object() || !params.contains("keys")) + throw AutomationError(kInvalidParams, "input.key requires 'keys'"); + const auto& k = params.at("keys"); + std::vector tokens; + if (k.is_string()) { + tokens = split(k.get(), '+'); + } else if (k.is_array()) { + for (const auto& e : k) + if (e.is_string()) tokens.push_back(e.get()); + } else { + throw AutomationError(kInvalidParams, "'keys' must be string or array"); + } + if (tokens.empty()) + throw AutomationError(kInvalidParams, "'keys' is empty"); + return { chord_from_tokens(tokens) }; +} +} // namespace + nlohmann::json JsonRpcDispatcher::m_version(const nlohmann::json&) { return { {"version", kAutomationVersion}, {"protocol", "2.0"}, @@ -134,9 +208,49 @@ nlohmann::json JsonRpcDispatcher::m_widget_get(const nlohmann::json& params) { return node_to_json(*node, /*include_children*/ true); } -nlohmann::json JsonRpcDispatcher::m_input_click(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); } -nlohmann::json JsonRpcDispatcher::m_input_type(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); } -nlohmann::json JsonRpcDispatcher::m_input_key(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); } +const UiNode JsonRpcDispatcher::resolve_actionable(const nlohmann::json& params, + UiNode& tree_out) { + if (!params.is_object() || !params.contains("target")) + throw AutomationError(kInvalidParams, "missing 'target'"); + m_backend.refresh_ui(); + tree_out = m_backend.dump_tree(DumpOptions{}); + int count = 0; + const UiNode* node = resolve_unique(tree_out, parse_target(params.at("target")), count); + if (count == 0) throw AutomationError(kErrNotFound, "target not found"); + if (count > 1) throw AutomationError(kErrNotFound, "target is ambiguous"); + if (!node->enabled || !node->visible) + throw AutomationError(kErrNotActionable, "target is disabled or hidden"); + return *node; // copy: stable even though tree_out outlives this call +} + +nlohmann::json JsonRpcDispatcher::m_input_click(const nlohmann::json& params) { + UiNode tree; + const UiNode node = resolve_actionable(params, tree); + const bool dbl = params.contains("double") && params.at("double").is_boolean() + && params.at("double").get(); + const bool ok = m_backend.click(node, parse_button(params), dbl, parse_modifiers(params)); + return { {"ok", ok} }; +} + +nlohmann::json JsonRpcDispatcher::m_input_type(const nlohmann::json& params) { + if (!params.is_object() || !params.contains("text") || !params.at("text").is_string()) + throw AutomationError(kInvalidParams, "input.type requires string 'text'"); + const std::string text = params.at("text").get(); + // Optional target: click to focus first. + if (params.contains("target")) { + UiNode tree; + const UiNode node = resolve_actionable(params, tree); + m_backend.click(node, MouseButton::Left, false, {}); + } + const bool ok = m_backend.type_text(text); + return { {"ok", ok} }; +} + +nlohmann::json JsonRpcDispatcher::m_input_key(const nlohmann::json& params) { + const bool ok = m_backend.send_keys(parse_keys(params)); + return { {"ok", ok} }; +} + nlohmann::json JsonRpcDispatcher::m_sync_wait_for(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); } nlohmann::json JsonRpcDispatcher::m_app_state(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); } nlohmann::json JsonRpcDispatcher::m_screenshot_window(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); } diff --git a/src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp b/src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp index ae42bb6e7c..1952e37fd1 100644 --- a/src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp +++ b/src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp @@ -49,6 +49,11 @@ private: nlohmann::json m_screenshot_window(const nlohmann::json& params); nlohmann::json m_screenshot_viewport3d(const nlohmann::json& params); + // Resolve a unique, actionable (enabled+visible) node from params["target"]. + // Throws kErrNotFound (missing/ambiguous) or kErrNotActionable (disabled/hidden). + // `tree_out` keeps the snapshot alive; the returned node is a stable copy. + const UiNode resolve_actionable(const nlohmann::json& params, UiNode& tree_out); + IUiBackend& m_backend; }; diff --git a/tests/automation/test_dispatcher.cpp b/tests/automation/test_dispatcher.cpp index 006b8d9cee..f2bb6ca742 100644 --- a/tests/automation/test_dispatcher.cpp +++ b/tests/automation/test_dispatcher.cpp @@ -88,3 +88,57 @@ TEST_CASE("widget.get not found -> 1001", "[automation][rpc]") { {"params",{{"target",{{"id","nope"}}}}}}); CHECK(resp.at("error").at("code") == kErrNotFound); } + +TEST_CASE("input.click resolves target and clicks it", "[automation][rpc]") { + MockUiBackend mock; mock.tree = dispatcher_tree(); + JsonRpcDispatcher d(mock); + const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","input.click"}, + {"params",{{"target",{{"id","btn_slice"}}}}}}); + CHECK(resp.at("result").at("ok") == true); + REQUIRE(mock.clicked_ids.size() == 1); + CHECK(mock.clicked_ids[0] == "btn_slice"); + CHECK(mock.click_buttons[0] == MouseButton::Left); +} + +TEST_CASE("input.click on disabled widget -> 1002", "[automation][rpc]") { + MockUiBackend mock; mock.tree = dispatcher_tree(); + JsonRpcDispatcher d(mock); + const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","input.click"}, + {"params",{{"target",{{"id","btn_export"}}}}}}); + CHECK(resp.at("error").at("code") == kErrNotActionable); + CHECK(mock.clicked_ids.empty()); +} + +TEST_CASE("input.type with target clicks to focus then types", "[automation][rpc]") { + MockUiBackend mock; mock.tree = dispatcher_tree(); + JsonRpcDispatcher d(mock); + const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","input.type"}, + {"params",{{"target",{{"id","btn_slice"}}},{"text","hello"}}}}); + CHECK(resp.at("result").at("ok") == true); + CHECK(mock.clicked_ids.size() == 1); // focused first + REQUIRE(mock.typed_text.size() == 1); + CHECK(mock.typed_text[0] == "hello"); +} + +TEST_CASE("input.key parses 'ctrl+s' string form", "[automation][rpc]") { + MockUiBackend mock; + JsonRpcDispatcher d(mock); + const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",4},{"method","input.key"}, + {"params",{{"keys","ctrl+s"}}}}); + CHECK(resp.at("result").at("ok") == true); + REQUIRE(mock.sent_keys.size() == 1); + REQUIRE(mock.sent_keys[0].size() == 1); + CHECK(mock.sent_keys[0][0].key == "s"); + REQUIRE(mock.sent_keys[0][0].modifiers.size() == 1); + CHECK(mock.sent_keys[0][0].modifiers[0] == KeyModifier::Ctrl); +} + +TEST_CASE("input.key parses array form [\"ctrl\",\"s\"]", "[automation][rpc]") { + MockUiBackend mock; + JsonRpcDispatcher d(mock); + const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",5},{"method","input.key"}, + {"params",{{"keys", json::array({"ctrl","s"})}}}}); + CHECK(resp.at("result").at("ok") == true); + REQUIRE(mock.sent_keys[0][0].modifiers.size() == 1); + CHECK(mock.sent_keys[0][0].key == "s"); +}