diff --git a/src/slic3r/GUI/Automation/IUiBackend.hpp b/src/slic3r/GUI/Automation/IUiBackend.hpp index 85db9abaef..17015b0447 100644 --- a/src/slic3r/GUI/Automation/IUiBackend.hpp +++ b/src/slic3r/GUI/Automation/IUiBackend.hpp @@ -101,6 +101,13 @@ public: // Throws AutomationError(kErrLoadFailed) when nothing loads. Header stays wx-free: // the concrete LoadStrategy is chosen inside WxUiBackend, not exposed here. virtual int open_files(const std::vector& paths) = 0; + + // Select a top-level view/tab by stable name (e.g. "prepare", "preview", "home", + // "device", "project", "calibration", "multi_device") on the GUI thread. Returns + // the resulting tab index. Throws AutomationError(kErrNotFound) when the named + // view is unknown or not available in the current layout. The wx-specific + // name->tab mapping lives in WxUiBackend/MainFrame, not here. + virtual int select_view(const std::string& view) = 0; }; }}} // namespace Slic3r::GUI::Automation diff --git a/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp b/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp index 0897a12983..118be2f879 100644 --- a/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp +++ b/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp @@ -154,6 +154,21 @@ std::vector parse_paths(const nlohmann::json& params) { throw AutomationError(kInvalidParams, "'paths' is empty"); return out; } + +// "view" must be a non-empty string naming a top-level tab. The name->tab mapping +// itself lives in the wx backend, so this only validates shape; throws kInvalidParams +// when view is missing, not a string, or empty. +std::string parse_view(const nlohmann::json& params) { + if (!params.is_object() || !params.contains("view")) + throw AutomationError(kInvalidParams, "view.select requires 'view'"); + const auto& v = params.at("view"); + if (!v.is_string()) + throw AutomationError(kInvalidParams, "'view' must be a string"); + std::string name = v.get(); + if (name.empty()) + throw AutomationError(kInvalidParams, "'view' is empty"); + return name; +} } // namespace namespace { @@ -196,7 +211,7 @@ nlohmann::json JsonRpcDispatcher::m_version(const nlohmann::json&) { {"capabilities", nlohmann::json::array({ "tree.dump","tree.find","widget.get","input.click","input.type", "input.key","sync.wait_for","app.state","screenshot.window", - "file.open" })} }; + "file.open","view.select" })} }; } nlohmann::json JsonRpcDispatcher::dispatch(const nlohmann::json& request) { @@ -222,6 +237,7 @@ nlohmann::json JsonRpcDispatcher::dispatch(const nlohmann::json& request) { if (method == "app.state") return make_result(id, m_app_state(params)); if (method == "screenshot.window") return make_result(id, m_screenshot_window(params)); if (method == "file.open") return make_result(id, m_file_open(params)); + if (method == "view.select") return make_result(id, m_view_select(params)); return make_error(id, kMethodNotFound, "unknown method: " + method); } catch (const AutomationError& e) { return make_error(id, e.code, e.what()); @@ -377,4 +393,10 @@ nlohmann::json JsonRpcDispatcher::m_file_open(const nlohmann::json& params) { return { {"ok", true}, {"loaded", loaded} }; } +nlohmann::json JsonRpcDispatcher::m_view_select(const nlohmann::json& params) { + const std::string view = parse_view(params); + const int index = m_backend.select_view(view); + return { {"ok", true}, {"view", view}, {"index", index} }; +} + }}} // namespace diff --git a/src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp b/src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp index 32c95bd8a6..e5611b7edd 100644 --- a/src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp +++ b/src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp @@ -49,6 +49,7 @@ private: nlohmann::json m_app_state(const nlohmann::json& params); nlohmann::json m_screenshot_window(const nlohmann::json& params); nlohmann::json m_file_open(const nlohmann::json& params); + nlohmann::json m_view_select(const nlohmann::json& params); // Resolve a unique, actionable (enabled+visible) node from params["target"]. // Throws kErrNotFound (missing/ambiguous) or kErrNotActionable (disabled/hidden). diff --git a/tests/automation/MockUiBackend.hpp b/tests/automation/MockUiBackend.hpp index 8485281e15..399050345e 100644 --- a/tests/automation/MockUiBackend.hpp +++ b/tests/automation/MockUiBackend.hpp @@ -20,6 +20,7 @@ public: std::vector> sent_keys; int screenshot_window_count = 0; std::vector> opened_paths; // paths of each open_files() + std::vector selected_views; // view of each select_view() // Canned outputs (set by tests). UiNode tree; // default tree for dump_tree @@ -28,6 +29,8 @@ public: bool click_result = true; int open_return_count = 0; // value open_files() returns bool open_should_fail = false; // when true, open_files() throws kErrLoadFailed + int select_view_index = 0; // value select_view() returns + bool select_view_should_fail = false; // when true, select_view() throws kErrNotFound // Optional: per-call tree provider (overrides `tree` when set). std::function tree_provider; @@ -59,6 +62,12 @@ public: throw AutomationError(kErrLoadFailed, "mock load failed"); return open_return_count; } + int select_view(const std::string& view) override { + selected_views.push_back(view); + if (select_view_should_fail) + throw AutomationError(kErrNotFound, "mock view not found"); + return select_view_index; + } }; }}} // namespace diff --git a/tests/automation/test_dispatcher.cpp b/tests/automation/test_dispatcher.cpp index 269c60bea0..3f1d24a346 100644 --- a/tests/automation/test_dispatcher.cpp +++ b/tests/automation/test_dispatcher.cpp @@ -267,3 +267,62 @@ TEST_CASE("automation.version capabilities include file.open", "[automation][rpc for (const auto& c : caps) if (c == "file.open") found = true; CHECK(found); } + +TEST_CASE("view.select routes the view name to the backend", "[automation][rpc]") { + MockUiBackend mock; + mock.select_view_index = 1; + JsonRpcDispatcher d(mock); + const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","view.select"}, + {"params",{{"view","prepare"}}}}); + CHECK(resp.at("result").at("ok") == true); + CHECK(resp.at("result").at("view") == "prepare"); + CHECK(resp.at("result").at("index") == 1); + REQUIRE(mock.selected_views.size() == 1); + CHECK(mock.selected_views[0] == "prepare"); +} + +TEST_CASE("view.select with missing view -> invalid params", "[automation][rpc]") { + MockUiBackend mock; + JsonRpcDispatcher d(mock); + const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","view.select"}, + {"params", json::object()}}); + CHECK(resp.at("error").at("code") == kInvalidParams); + CHECK(mock.selected_views.empty()); +} + +TEST_CASE("view.select with non-string view -> invalid params", "[automation][rpc]") { + MockUiBackend mock; + JsonRpcDispatcher d(mock); + const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","view.select"}, + {"params",{{"view", 42}}}}); + CHECK(resp.at("error").at("code") == kInvalidParams); + CHECK(mock.selected_views.empty()); +} + +TEST_CASE("view.select with empty view -> invalid params", "[automation][rpc]") { + MockUiBackend mock; + JsonRpcDispatcher d(mock); + const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",4},{"method","view.select"}, + {"params",{{"view",""}}}}); + CHECK(resp.at("error").at("code") == kInvalidParams); + CHECK(mock.selected_views.empty()); +} + +TEST_CASE("view.select unavailable view -> not found (1001)", "[automation][rpc]") { + MockUiBackend mock; + mock.select_view_should_fail = true; + JsonRpcDispatcher d(mock); + const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",5},{"method","view.select"}, + {"params",{{"view","calibration"}}}}); + CHECK(resp.at("error").at("code") == kErrNotFound); +} + +TEST_CASE("automation.version capabilities include view.select", "[automation][rpc]") { + MockUiBackend mock; + JsonRpcDispatcher d(mock); + const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",6},{"method","automation.version"}}); + const auto& caps = resp.at("result").at("capabilities"); + bool found = false; + for (const auto& c : caps) if (c == "view.select") found = true; + CHECK(found); +}