Merge branch 'main' into feat/printer-agent-infra

This commit is contained in:
Ian Chua
2026-09-22 20:55:29 +08:00
88 changed files with 13028 additions and 6155 deletions
+1
View File
@@ -16,6 +16,7 @@ add_executable(${_TEST_NAME}_tests
test_printer_agent.cpp
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_plugin_printer_agent.cpp
test_slicing_pipeline_bindings.cpp
test_slicing_pipeline_config.cpp
test_plugin_sort.cpp
@@ -3,8 +3,12 @@
#include <libslic3r/Model.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <libslic3r/TriangleMesh.hpp>
#include <slic3r/GUI/DockPanel.hpp>
#include <slic3r/GUI/AuiPaneLayout.hpp>
#include <slic3r/GUI/Widgets/WebHosting.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include "plugin_test_utils.hpp"
#include "python_test_support.hpp"
#include <pybind11/embed.h>
@@ -141,6 +145,8 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
CHECK(ui.attr("WINDOW_MODELESS").cast<long>() == 0);
CHECK(ui.attr("WINDOW_MODAL").cast<long>() == 1);
CHECK(has_attr(ui, "UiWindow"));
CHECK(has_attr(ui, "create_dock_panel"));
CHECK(has_attr(ui, "UiDockPanel"));
// With no wx application the UI calls marshal to a main thread that does not
// exist here; they must fail cleanly with a clear error, not crash.
@@ -151,6 +157,74 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
try {
ui.attr("create_dock_panel")("<p>panel</p>");
FAIL("orca.host.ui.create_dock_panel unexpectedly succeeded without a wx application");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
// Positional arguments follow create_window(): width and height come straight after the title.
try {
ui.attr("create_dock_panel")("<p>panel</p>", "Panel", 400, 300);
FAIL("orca.host.ui.create_dock_panel unexpectedly succeeded without a wx application");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_RuntimeError));
}
// An unknown dock position is rejected before the application is needed.
try {
ui.attr("create_dock_panel")("<p>panel</p>", py::arg("dock") = "top");
FAIL("orca.host.ui.create_dock_panel accepted an unknown dock position");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_ValueError));
}
}
TEST_CASE("Plugin pane names identify the plugin and title without layout delimiters", "[PluginHost]")
{
using Slic3r::GUI::plugin_pane_name;
CHECK(plugin_pane_name("dock_demo", "Scene") == "plugin:dock_demo:Scene");
CHECK(plugin_pane_name("key", "a|b;c=d\\e").find_first_of("|;=\\") == std::string::npos);
}
TEST_CASE("A pane's saved layout entry is found by pane name", "[PluginHost]")
{
using Slic3r::GUI::aui_pane_layout_entry;
const std::string sidebar = "name=sidebar;caption=;state=2099196;dir=4;layer=0;row=0;pos=0;bestw=390;besth=900";
// The caption holds an escaped '|', which must not end the entry.
const std::string plugin = "name=plugin:demo:Scene;caption=Scene \\| stats;state=2099198;dir=2;layer=0;row=1;pos=0;bestw=320;besth=480";
const std::string layout = "layout3|" + sidebar + "|" + plugin + "|dock_size(4,0,0)=392|";
CHECK(aui_pane_layout_entry(layout, "plugin:demo:Scene") == plugin);
CHECK(aui_pane_layout_entry(layout, "sidebar") == sidebar);
CHECK(aui_pane_layout_entry(layout, "plugin:demo").empty());
CHECK(aui_pane_layout_entry("", "plugin:demo:Scene").empty());
}
TEST_CASE("A reloaded plugin page is recognised by its base URL, fragment aside", "[PluginHost]")
{
using namespace Slic3r::GUI::web_hosting;
// A resources path holding a space, which the web view reports escaped.
const Slic3r::ScopedResourcesDir resources("web content check");
// The swapped-in page, then after an in-page anchor and a reload.
CHECK(is_content_url(content_base_url()));
CHECK(is_content_url(content_base_url() + "#tab2"));
wxString escaped = content_base_url();
escaped.Replace(" ", "%20");
REQUIRE(escaped != content_base_url());
CHECK(is_content_url(escaped));
CHECK(is_content_url(escaped + "#tab2"));
// A page the plugin linked to keeps its own URL and must be left alone.
CHECK_FALSE(is_content_url(content_base_url() + "guide.html"));
CHECK_FALSE(is_content_url("https://example.com/"));
CHECK_FALSE(is_content_url(""));
}
TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]")
@@ -0,0 +1,161 @@
#include <catch2/catch_all.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/Utils/IPrinterAgent.hpp>
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <memory>
#include <string>
namespace py = pybind11;
using namespace Slic3r;
namespace {
// Same idiom as ScopedPluginManager in test_plugin_lifecycle.cpp: the trampolines refuse to call
// into Python unless PythonInterpreter::instance() reports initialized.
struct ScopedPluginManager
{
bool initialized = PluginManager::instance().initialize();
~ScopedPluginManager()
{
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
};
// The host reaches a printer agent plugin through IPrinterAgent, so the tests do too. The Python
// instance carries the overrides, so it has to outlive every call, as PluginInstanceHandle ensures
// in production.
struct Agent
{
py::object instance;
std::shared_ptr<IPrinterAgent> agent;
IPrinterAgent* operator->() const { return agent.get(); }
IPrinterAgent& operator*() const { return *agent; }
};
Agent make_agent(const std::string& body)
{
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
py::dict globals;
globals["orca"] = py::module_::import("orca");
py::exec("class Agent(orca.printer_agent.PrinterAgentBase):\n"
" def get_name(self): return 'agent'\n" + body, globals);
py::object instance = globals["Agent"]();
auto capability = instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
capability->set_audit_plugin_key("agent_plugin");
return {instance, std::dynamic_pointer_cast<IPrinterAgent>(capability)};
}
// One operation per return type and dispatch shape; the rest share their macro.
const std::string OPERATIONS[] = {"get_agent_info", "disconnect_printer", "start_discovery", "get_user_selected_machine",
"get_filament_sync_mode", "install_device_cert", "start_local_print", "request_bind_ticket"};
// What NetworkAgent answers when no printer agent is set.
void check_answers_like_no_agent(IPrinterAgent& agent)
{
std::string ticket = "untouched";
CHECK(agent.get_agent_info().id.empty());
CHECK(agent.disconnect_printer() == -1);
CHECK_FALSE(agent.start_discovery(true, false));
CHECK(agent.get_user_selected_machine().empty());
CHECK(agent.get_filament_sync_mode() == FilamentSyncMode::none);
CHECK_NOTHROW(agent.install_device_cert("dev", true));
CHECK(agent.start_local_print(PrintParams{}, nullptr, nullptr) == -1);
CHECK(agent.request_bind_ticket(&ticket) == -1);
CHECK(ticket == "untouched");
}
std::string define_all(const std::string& signature_tail, const std::string& statement)
{
std::string body;
for (const std::string& operation : OPERATIONS)
body += " def " + operation + "(self" + signature_tail + "): " + statement + "\n";
return body;
}
} // namespace
TEST_CASE("A printer agent operation that raises answers like a missing agent", "[PluginPrinterAgent][Python]")
{
ScopedPluginManager plugin_system; // declared first: destroyed last
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
auto agent = make_agent(define_all(", *args", "raise RuntimeError('boom')"));
REQUIRE(agent.agent);
check_answers_like_no_agent(*agent);
// The interpreter stays usable.
CHECK(py::eval("1 + 1").cast<int>() == 2);
}
TEST_CASE("A printer agent that omits its operations answers like a missing agent", "[PluginPrinterAgent][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil;
auto agent = make_agent("");
REQUIRE(agent.agent);
check_answers_like_no_agent(*agent);
}
TEST_CASE("A printer agent operation returning the wrong type answers like a missing agent", "[PluginPrinterAgent][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil;
auto agent = make_agent(define_all(", *args", "return object()"));
REQUIRE(agent.agent);
check_answers_like_no_agent(*agent);
}
TEST_CASE("A working printer agent's answers reach the host unchanged", "[PluginPrinterAgent][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil;
auto agent = make_agent(" def get_agent_info(self): return orca.printer_agent.AgentInfo('id', 'name', '1', 'description')\n"
" def disconnect_printer(self): return 7\n"
" def start_discovery(self, start, sending): return start and not sending\n"
" def get_user_selected_machine(self): return 'machine'\n"
" def get_filament_sync_mode(self): return orca.printer_agent.FilamentSyncMode.Pull\n"
" def request_bind_ticket(self): return (3, 'ticket')\n"
" def bind_detect(self, dev_ip, sec_link, detect):\n"
" detect.dev_id = dev_ip\n"
" return 0\n");
REQUIRE(agent.agent);
std::string ticket;
detectResult detect;
CHECK(agent->get_agent_info().id == "id");
CHECK(agent->disconnect_printer() == 7);
CHECK(agent->start_discovery(true, false));
CHECK(agent->get_user_selected_machine() == "machine");
CHECK(agent->get_filament_sync_mode() == FilamentSyncMode::pull);
CHECK(agent->request_bind_ticket(&ticket) == 3);
CHECK(ticket == "ticket");
CHECK(agent->bind_detect("192.168.0.2", "secure", detect) == 0);
CHECK(detect.dev_id == "192.168.0.2");
}